Skip to content

Skip library unloading during VM finalization - #1194

Open
selesse wants to merge 1 commit into
ffi:masterfrom
selesse:rtld-nodelete
Open

selesse wants to merge 1 commit into
ffi:masterfrom
selesse:rtld-nodelete

Conversation

@selesse

@selesse selesse commented Aug 21, 2026

Copy link
Copy Markdown

FFI::DynamicLibrary calls dlclose when garbage collection frees it. During VM finalization CRuby force-frees eligible T_DATA objects, including reachable library handles, so it can unmap code while foreign native threads are still executing it. This caused the Shopify worker shutdown crashes we've been chasing through librdkafka.

I initially tried making RTLD_NODELETE the default, but that changed normal library lifetime and static-state behavior. This version leaves the existing flags and runtime unloading alone. ffi keeps a private rooted sentinel whose finalizer marks the finalization phase after END handlers and before forced T_DATA cleanup; library_free skips dlclose only from that point onward.

The regressions distinguish those phases: normal GC still unloads, an at_exit handler registered before ffi still unloads, and a child with active native threads survives the forced shutdown sweep. The at_exit case fails with the earlier rb_set_end_proc approach.

Bindings still own orderly cleanup—the corresponding rdkafka fix is karafka/rdkafka-ruby#964. This is the narrow safety net when that cleanup is incomplete or fails. An embedded host that destroys Ruby but keeps its process alive will retain mappings that reach forced finalization; standalone Ruby processes are about to return the whole address space to the OS.

@selesse
selesse marked this pull request as ready for review August 21, 2026 19:31
@selesse selesse changed the title Load libraries with RTLD_NODELETE Keep default ffi_lib libraries loaded Aug 31, 2026
@larskanis

Copy link
Copy Markdown
Member

Thank you for your investigation and the update! Wouldn't it make sense to enable dlclose on Macos now?

@selesse

selesse commented Sep 1, 2026

Copy link
Copy Markdown
Author

Wouldn't it make sense to enable dlclose on Macos now?

@larskanis Yes, and I pulled that out into its own PR in #1199. Since it's been disabled for a long time I'd rather an explicit PR than bundling it here, but happy to merge them if you prefer!

@headius

headius commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

At process exit Ruby's shutdown sweep frees everything, even objects that are still referenced. If a native library owns background threads, those threads can wake up in code that isn't mapped anymore.

This seems like a bug in either the native code or in CRuby. Shouldn't these threads be shut down before the GC starts clearing referenced objects?

@selesse

selesse commented Sep 1, 2026

Copy link
Copy Markdown
Author

This seems like a bug in either the native code or in CRuby. Shouldn't these threads be shut down before the GC starts clearing referenced objects?

Ideally, yes. I checked both CRuby 3.4.8 and 4.0.6: they stop Ruby Threads, run ObjectSpace finalizers, then free T_DATA objects (including still-referenced DynamicLibrary handles).

These aren't Ruby threads, though. They're pthreads created inside librdkafka, so CRuby can't see or stop them. rd_kafka_destroy does that, and rdkafka-ruby normally calls it from a finalizer. The gap is that rd_kafka_new can succeed before the Ruby wrapper and its finalizer are installed. If later setup raises, the native client is orphaned and Ruby has nothing left to close.

So I agree the binding should clean up first, and we're fixing that path too. NODELETE is the safety net when cleanup never gets a chance to run; it isn't a replacement for cleanup.

@eregon

eregon commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

The gap is that rd_kafka_new can succeed before the Ruby wrapper and its finalizer are installed. If later setup raises, the native client is orphaned and Ruby has nothing left to close.

This sounds like an unlikely edge case/a bug in rdkafka-ruby, if it can happen.

I'm negative on this, it's basically leaking memory to workaround missing necessary cleanup.
In your example, it doesn't seem any good to still have thirty threads per client still running, they should be properly shut down.

@eregon

eregon commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

BTW I wonder if dlclose() does anything when passed RTLD_NODELETE.
Also the man page says:

       RTLD_NODELETE (since glibc 2.2)
              Do not unload the shared object during dlclose().  Consequently, the object's static and global variables are not  reinitialized  if  the
              object is reloaded with dlopen() at a later time.

Which is unsafe if there is another dlopen() for that same library later, and will likely result in a harder-to-diagnose crash.

@eregon

eregon commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

If changing anything, I would simply not call dlclose().
Using RTLD_NODELETE seems pretty hacky and seems a workaround for something more fundamental going wrong.

@selesse

selesse commented Sep 2, 2026

Copy link
Copy Markdown
Author

I'm coming around to NODELETE being too broad here. I'm working on a fix in rdkafka (karafka/rdkafka-ruby#964).

What if this PR only skipped dlclose once CRuby starts shutting down? Normal GC and explicit unloads would keep working exactly as they do today. There isn't much value in unloading a library while the process is already on its way out, and it avoids changing the default lifetime of every ffi library.

That would also keep #1199 straightforward: enable dlclose on macOS during normal execution, but not from the shutdown sweep. Does that direction sound better?

@eregon

eregon commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

I'm coming around to NODELETE being too broad here. I'm working on a fix in rdkafka (karafka/rdkafka-ruby#964).

Nice 👍

What if this PR only skipped dlclose once CRuby starts shutting down?

Is that necessary, for what?

Normal GC and explicit unloads would keep working exactly as they do today. There isn't much value in unloading a library while the process is already on its way out, and it avoids changing the default lifetime of every ffi library.

One concern is there can be something very long in at_exit, for example a test suite or webserver running in it.
IOW, at_exit isn't the same as shutting down.

CRuby force-frees referenced `T_DATA` objects during VM finalization.
`FFI::DynamicLibrary` normally responds by calling `dlclose`, which can
unmap a library while its foreign native threads are still running.
This has caused production shutdown crashes through librdkafka.

Use a rooted sentinel's finalizer to mark the finalization phase before
CRuby reaches forced `T_DATA` cleanup. Ordinary garbage collection and
END handlers retain their existing unload behavior.

Co-Authored-By: Pi <pi@shopify.com>
@selesse selesse changed the title Keep default ffi_lib libraries loaded Skip library unloading during VM finalization Sep 2, 2026
@selesse

selesse commented Sep 2, 2026

Copy link
Copy Markdown
Author

One concern is there can be something very long in at_exit, for example a test suite or webserver running in it.
IOW, at_exit isn't the same as shutting down.

Good point, rb_set_end_proc is too early. I reworked this around a private rooted object with a finalizer. CRuby runs that after all at_exit/END handlers and before forced T_DATA cleanup, so normal GC and at_exit still unload libraries; only the forced sweep skips dlclose.

I still think the ffi part is useful as a safety net. The binding should stop its own threads (karafka/rdkafka-ruby#964 does that for rdkafka) but if cleanup is incomplete, the forced sweep currently turns that into a SIGSEGV by unmapping code under the remaining threads.

I added three child-process checks: ordinary GC still unloads, GC from an at_exit handler registered before ffi still unloads, and forced shutdown no longer crashes with active native threads. The END-proc version fails the at_exit case. In the real rdkafka repro, stock ffi crashed in 13/20 children and this version completed 20/20 cleanly.

Does that work?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants