diff --git a/bench/README.md b/bench/README.md index 12f2b1f..665dc22 100644 --- a/bench/README.md +++ b/bench/README.md @@ -391,6 +391,32 @@ changed from 0.352 seconds to 0.021 seconds. Peak mark-table entries in these runs changed from 301,767 to 1,772. Both builds print the same result. These numbers measure this collector workload, not the duration of the CI workflow. +## `exit_gc_workload.rb` + +```sh +ruby bench/exit_gc_workload.rb [temporary_errors] [collections] +./mere-ruby bench/exit_gc_workload.rb [temporary_errors] [collections] +``` + +The final class body creates 20,000 temporary conversion errors by default. +An exit hook collects them, reports the remaining count, and times 20 further +collections. Stdout checks that the captured result survives. The interpreter +uses conservative stack roots, so its retained count need not be exactly zero. + +On Apple silicon at O2 with Mere `991f299b`, against mere-ruby `23ce853` +(2026-11-07), two runs retained all 20,000 errors before this fix and 126 after. +The timed collections took 0.99–1.35 seconds before and 0.033–0.042 seconds +after. Stdout matched Ruby 4.0.9. + +The driver now ends the completed top-level statement's temporary allocation +interval before running exit hooks. Its result remains explicitly rooted in +`gc_hold`, and captured values retain their usual roots. This particularly +affects test runners that execute their suites from an exit hook. A local +`test_UTF-8_upcase_ascii` comparison went from about 121 to 7.5 seconds in the +test body, with all 40,569 assertions passing; initialization adds roughly a +minute and is not included in those body times. Concurrent validation limits +timing precision. + ## `file_read_checks.rb` ```sh diff --git a/bench/exit_gc_workload.rb b/bench/exit_gc_workload.rb new file mode 100644 index 0000000..953a2a4 --- /dev/null +++ b/bench/exit_gc_workload.rb @@ -0,0 +1,29 @@ +# A final class body creates temporary conversion errors, then exit hooks +# perform repeated collections. Count retained errors and time the actual GC. +count = Integer(ARGV.fetch(0, '20000')) +rounds = Integer(ARGV.fetch(1, '20')) +at_exit do + GC.enable + GC.start + warn "retained_errors=#{ObjectSpace.each_object(Encoding::UndefinedConversionError).count}" + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + rounds.times { GC.start } + warn "collections=#{rounds} seconds=#{Process.clock_gettime(Process::CLOCK_MONOTONIC) - start}" + p ExitGCWorkload.new.kept +end +GC.disable +Object.const_set(:EXIT_GC_INPUT, count) +class ExitGCWorkload + def self.build + codepoints = Array.new(EXIT_GC_INPUT) { |i| (0x1000 + i % 0x1000).chr('UTF-8') }.select do |code| + begin + code.encode('US-ASCII') + true + rescue Encoding::UndefinedConversionError + false + end + end + define_method(:kept) { codepoints } + end + build +end diff --git a/corpus/504_completed_statement_exit_roots.rb b/corpus/504_completed_statement_exit_roots.rb new file mode 100644 index 0000000..acf9dda --- /dev/null +++ b/corpus/504_completed_statement_exit_roots.rb @@ -0,0 +1,26 @@ +# Exit hooks must keep captured values, but not every temporary allocated by +# the program's final statement. A conservative collector may retain a few +# incidental handles; retaining most of this completed workload is a leak. +at_exit do + GC.enable + GC.start + p ObjectSpace.each_object(Encoding::UndefinedConversionError).count < 1000 + p ExitRootFixture.new.kept +end +GC.disable + +class ExitRootFixture + def self.build + kept = ['kept', {answer: [42]}] + selected = (0..4000).map { |i| (i + 0x1000).chr('UTF-8') }.select do |code| + begin + code.encode('US-ASCII') + true + rescue Encoding::UndefinedConversionError + false + end + end + define_method(:kept) { [kept, selected] } + end + build +end diff --git a/m_driver.mere b/m_driver.mere index bcf6aa5..45175e7 100644 --- a/m_driver.mere +++ b/m_driver.mere @@ -12533,6 +12533,10 @@ let run_src = fn (src0: str) -> fn (print_result: bool) -> fn (name: str) -> else gc_drive (gc_total + 2) lastv) else // ---- finish: reporting, at_exit, optional print ---- + // The last top-level statement has finished. Its temporary allocation + // interval must not become a root for the entire at_exit workload. + // gc_drive already pinned lastv in gc_hold for optional result printing. + let _ = fb_wm_stmt () in let (meths, sup, ocls, ivars) = world in // (=exit prints them after the at_exit handlers instead: all of a // test/unit file runs inside one, so before them it reads nothing)