Bug #22339
openRuby::Box: crash / hang at VM shutdown with RUBY_FREE_AT_EXIT=1
Description
Summary¶
With RUBY_BOX=1 and RUBY_FREE_AT_EXIT=1, Ruby crashes or hangs while freeing boxes at VM
shutdown. No user box needs to be created: the built-in boxes are enough.
box_entry_free() walks box->classext_cow_classes at shutdown, but the objects in that table
have already been freed by the shutdown sweep, so free_classext_for_box() sees a type that is
no longer T_CLASS/T_MODULE/T_ICLASS and calls rb_bug(). Under ASAN the same teardown is
caught one step earlier, as an iclass classext freed twice.
Reproduction¶
Either environment variable alone is fine; only the combination fails.
Observed behaviour¶
ruby 4.1.0dev (2026-09-21T14:42:24Z master 41e04ff507) +PRISM [arm64-darwin25] hangs, spinning
at 99% CPU. sample(1) of the main thread:
rb_ec_cleanup
rb_objspace_call_finalizer
rb_gc_obj_free
box_entry_free (box.c, st_foreach over classext_cow_classes)
st_general_foreach
free_classext_for_box
rb_bug ("Invalid type of object in classext_cow_classes: %s")
rb_bug_without_die_internal
rb_source_location_cstr
_sigtramp <- SIGSEGV while printing the bug report
sigsegv
rb_bug_for_fatal_signal
rb_source_location_cstr
rb_iseq_path <- loops here forever
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] (docker
ruby:4.0.7-slim-trixie) dies with SIGSEGV (exit 139, core dumped) instead of hanging.
The offending branch is in free_classext_for_box():
if (RB_TYPE_P(obj, T_CLASS) || RB_TYPE_P(obj, T_MODULE)) { ... }
else if (RB_TYPE_P(obj, T_ICLASS)) { ... }
else {
rb_bug("Invalid type of object in classext_cow_classes: %s", rb_type_str(BUILTIN_TYPE(obj)));
}
ASAN evidence¶
A Linux ASAN build of master reports a use-after-free in the same shutdown teardown, where the
same id_table is freed twice through the identical call path:
ERROR: AddressSanitizer: heap-use-after-free, READ of size 8
#0 rb_id_table_free id_table.c:112:16
#1 rb_iclass_classext_free class.c:128:9
#2 rb_class_classext_foreach class.c:460:5
#3 rb_gc_obj_free gc.c:1402:9
#4 rb_gc_impl_shutdown_call_finalizer gc/default/default.c:3087:25
#5 rb_objspace_call_finalizer gc.c:1739:5
#6 rb_ec_finalize eval.c:177:5
#7 rb_ec_cleanup eval.c:268:5
freed by thread T0 here:
#0 free
#1 rb_gc_impl_free gc/default/default.c:8285:9
#2 rb_iclass_classext_free class.c:128:9
#3 rb_class_classext_foreach class.c:460:5
#4 rb_gc_obj_free gc.c:1402:9
#5 rb_gc_impl_shutdown_call_finalizer gc/default/default.c:3087:25
#6 rb_objspace_call_finalizer gc.c:1739:5
That build additionally had a user box created before exit; the rb_bug reproduction above does
not need one.
Affected versions¶
Same one-liner on each build:
| build | result |
|---|---|
master 41e04ff507, arm64-darwin25 |
hang (99% CPU) |
| 4.0.7, 4.0.6, 4.0.3 (ruby-build), arm64-darwin25 | hang (99% CPU) |
4.0.7 linux (ruby:4.0.7-slim-trixie, ruby:4.0.7-slim-bookworm) |
SIGSEGV |
4.0.6 linux (ruby:4.0.6-slim-trixie) |
SIGSEGV |
4.0.5, 4.0.3, 4.0.2, 4.0.1 linux (ruby:4.0.x-slim, -slim-trixie) |
exits 0 |
The base image makes no difference (bookworm and trixie behave the same), so this is the Ruby
build rather than the distribution. Since the underlying defect is a use-after-free, the rows
that exit 0 are presumably latent rather than unaffected: whether the freed object still reads
back as a class depends on allocator reuse.
Possibly a separate issue¶
Once rb_bug() is reached at this point in shutdown, the bug reporter itself crashes in
rb_source_location_cstr(), and the fatal signal handler re-enters the same function, so the
process never terminates. A rb_bug() this late should still be able to abort.
No data to display