Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions bench/exit_gc_workload.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions corpus/504_completed_statement_exit_roots.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
284km marked this conversation as resolved.
Resolved
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
4 changes: 4 additions & 0 deletions m_driver.mere
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
284km marked this conversation as resolved.
Resolved
Comment thread
284km marked this conversation as resolved.
Resolved
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)
Expand Down
Loading