Project

General

Profile

Actions

Bug #22122

closed

A Ractor/Ractor::Port memory leak (or so it would seem)

Bug #22122: A Ractor/Ractor::Port memory leak (or so it would seem)

Added by miles-georgi (Miles Georgi) 4 months ago. Updated 26 days ago.

Status:
Closed
Assignee:
Target version:
-
ruby -v:
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
[ruby-core:125798]

Description

Hi!

If I do something like this:

#!/usr/bin/env ruby

1000.times do
  return_port = Ractor::Port.new
  ractor = Ractor.new(return_port) do |return_port|
    return_port << Ractor::Port.new while receive
  end

  10_000.times { ractor << true }
  # If you uncomment this then the leak goes away:
  # 10_000.times { return_port.receive }
  ractor << nil
  ractor.join
end

GC.start

ractor_count = 0
port_count = 0
ObjectSpace.each_object do
  ractor_count += 1 if Ractor === it
  port_count += 1 if Ractor::Port === it
end
puts "ractors: #{ractor_count} ports: #{port_count}"

Then I see:

ractors: 1001 ports: 10001001

Note that you can also just make the number of loops super big or infinite and just watch the memory grow.

But a possible hint: if I uncomment that line that pulls stuff out of the ports it's creating, then I get:

ractors: 1 ports: 1

Which is what I would expect since nothing references any of the ractors or ports created by the script and the ractors have all terminated.

I can reproduce in both:

ruby 4.1.0dev (2026-06-19T18:04:54Z master 1c1dafa769) +PRISM [x86_64-linux]

and

ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]

Attached is the script.

Thanks!


Files

ractor-and-port-leak-script (542 Bytes) ractor-and-port-leak-script miles-georgi (Miles Georgi), 06/19/2026 07:24 PM

Updated by luke-gru (Luke Gruber) 3 months ago Actions #1

  • Assignee set to ractor

Updated by ko1 (Koichi Sasada) 26 days ago Actions #2

  • Status changed from Open to Closed

Applied in changeset git|ae5ca459442a22ab5966e1aff644e18473f87369.


Free messages addressed to a port that died before delivery

A message sent to a Ractor::Port is enqueued on the receiving Ractor's
recv_queue, and only moved to that port's own queue when the Ractor
receives or closes. rb_ractor_reap_dead_ports() sweeps the port queues
held in sync.ports, so a message whose port became unreachable before any
delivery happened was never reaped: it stayed on recv_queue, off-heap and
invisible to ObjectSpace, for the life of the process.

200.times { p = Ractor::Port.new; 1000.times { p << ("z" * 4000) } }

grew without bound (RSS 858 -> 1692 -> 2523 MB over three such passes),
and needs no Ractor at all to hit. Closing the port, or any receive on
the same Ractor, moved the messages to the port queue and hid the leak.

Sweep recv_queue in the reap as well, dropping the baskets whose port is
no longer in the table. The reap takes no sync lock: what keeps out the
foreign senders that write recv_queue is the caller's gate in
rb_ractor_finish_marking(), where a global GC has the world stopped and a
single objspace has exactly one live Ractor. Assert that at the callee,
so a second caller cannot lose the guarantee silently.

[Bug #22122]

Co-Authored-By: Claude Opus 5 (1M context)

Actions

Also available in: PDF Atom