I run a free image converter, its built on rust + axum + libvips. it had been happily running for months. then one feature, images-to-pdf, started falling over in production with this:
g_object_unref: assertion 'G_IS_OBJECT (object)' failed
and the fun part: only sometimes. convert a couple images, fine. convert a few more, boom. the non-deterministic kind of crash, which is the worst kind, because it makes you sit there wondering if you even understand the language you've been writing for years.
Turns out the bug was mine. the trigger was one tiny innocent .clone(). heres the whole thing because i'm pretty sure other people doing rust over a C lib will eventually step on the same rake.
"But rust is memory safe"
This is what messed with my head for a while. rust is supposed to stop exactly this. no double frees, no use after free, borrow checker has your back. so how am i double freeing anything at all?
One word makes all those guarantees quietly disappear: FFI.
Libvips is a C library built on GObject, and GObject does its own manual reference counting. the rust crate wraps those C objects in rust types. and the second a rust type is holding a raw pointer into C land, the borrow checker just... cant help you anymore. it sees a struct. it does not see that the struct is actually a handle to a refcounted thing living on the C heap. safety stops right at that line and nobody tells you.
The actual cause: a Clone that doesnt clone
Heres roughly what was blowing up. i was building a multi-page pdf and somewhere in the loop i cloned the image:
// the crash version
let copy = image.clone();
// ... use copy ...
// both `image` and `copy` drop here
VipsImage in the crate derives Clone. and a derived clone on a thing that holds a pointer does the obvious dumb thing: it copies the pointer. bit for bit. so now the same C object has two rust owners, and here is the kicker, nobody incremented the GObject refcount. libvips still thinks there is exactly one reference to that object.
Then rust does its job a little too well. both values go out of scope, Drop runs twice, and each drop calls g_object_unref on the same object. first unref takes the count to zero and frees it. second unref runs on already-freed memory, libvips checks G_IS_OBJECT, the assertion blows, and depending on timing you either get a glib critical or a clean segfault.
So. a textbook double free. wearing a .clone() as a disguise. the "random" part was just the allocator deciding whether the freed slot had been reused yet or not.
A correct clone here would have to call g_object_ref so each owner actually accounts for its own reference. the derived one doesnt. so calling it Clone is generous. its an aliased pointer with two destructors aimed at it.
The fix
Stopped cloning the handle, started asking libvips for a real new object instead:
// the fixed version
let copy = ops::copy(&image)?;
ops::copy runs an actual libvips copy and gives you back a fresh GObject with its own refcount. now each rust value owns its own distinct C object, each drop unrefs its own thing, nothing gets freed twice. crash gone.
one line. took me a couple evenings to be sure it was that line, which is always how these go.
a few more libvips + rust landmines from the same evening
since i was already in there bleeding, a few neighbours showed up:
JpegsaveBufferOptions::default() hands you a busted keep value. the default came out as something like keep | 32, which libvips just rejects with a glib critical. fix was to stop trusting the default and set it myself:
keep: ops::ForeignKeep::None,
the operation cache was a second way to die. libvips caches operations by default, and under load that cache plus the lifecycle mess above gave me another crash, this time inside cache trimming. i turned the operation cache off entirely while getting things stable, which killed that path. just know it exists and that its global state.
VipsApp::new(name, false) is not what it looks like. that second bool is detect_leak, not some concurrency switch, which is exactly what i had lazily assumed it was. read the signature, dont pattern-match on vibes.
bumping the crate did not save me. i tried going from 1.7.x up to 2.1.0 hoping the lifecycle stuff was just fixed upstream. nope, changed nothing for this. reverted to 1.7.6 and fixed it properly on my end.
what i actually took from this
a derived Clone on a type that wraps a foreign, refcounted resource is a trap. #[derive(Clone)] assumes a clone is a cheap copy-the-fields job. for a GObject handle thats the exact wrong assumption: copy the pointer without taking a ref and you've got two owners, one refcount, and rust's own Drop happily freeing it twice.
so the rule i walked out with: when a rust type owns a C resource that has its own lifecycle, treat Clone and Drop as suspects, not freebies. need another handle? reach for the library's own copy/ref function before you derive Clone and hope.
if you've hit something like this with libvips bindings or any GObject FFI, i'd genuinely love to hear how you dealt with it, the comments are the fun part. the tool this was all holding up is at convertifyapp.net if you want a look. no account, files get deleted after.