-
-
Notifications
You must be signed in to change notification settings - Fork 945
Fix runtime and classloader retention after ScriptingContainer terminate #9315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ef9bad4
20d9e85
c2424de
4991b17
77d52d4
b561b86
7a17756
95c1975
4e6bf22
4b45ea0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,6 +104,12 @@ public void remove() { | |
| if (varMap != null) { | ||
| synchronized(this) { varMap.clear(); } | ||
| } | ||
| // Allow the Ruby runtime to be GC-eligible immediately after terminate(). | ||
|
headius marked this conversation as resolved.
|
||
| // Without this, the strong reference chain | ||
| // ScriptingContainer -> SingleThreadLocalContextProvider -> LocalContext -> Ruby | ||
| // keeps the runtime (and its JRubyClassLoader) alive until this LocalContext | ||
| // is itself collected, which may be delayed by ScriptingContainer.finalize(). | ||
| runtime = null; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change is accepted. It may not be necessary, but it's reasonable to clear a reference to the runtime. I'm adding a termination flag to ensure the context is not able to be re-vivified. See #9359. |
||
| } | ||
|
|
||
| Ruby getRuntime() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,11 +32,13 @@ | |
| package org.jruby.internal.runtime; | ||
|
|
||
| import java.lang.ref.SoftReference; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import java.util.concurrent.atomic.AtomicLong; | ||
| import java.util.concurrent.locks.ReentrantLock; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| import java.util.WeakHashMap; | ||
|
|
@@ -131,6 +133,12 @@ public class ThreadService extends ThreadLocal<SoftReference<ThreadContext>> { | |
|
|
||
| private final AtomicLong threadCount = new AtomicLong(0); | ||
|
|
||
| /** | ||
| * All SoftReferences ever stored into this ThreadLocal (one per thread that has entered Ruby | ||
| * space on this runtime). Tracked so that teardown() can clear their referents even on | ||
| * threads we cannot schedule work on (adopted / pool threads). | ||
| */ | ||
| private final List<SoftReference<ThreadContext>> trackedRefs = new CopyOnWriteArrayList<>(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change is rejected.
|
||
| public ThreadService(final Ruby runtime) { | ||
| this.runtime = runtime; | ||
|
|
||
|
|
@@ -167,6 +175,29 @@ public void teardown() { | |
|
|
||
| // clear thread map | ||
| rubyThreadMap.clear(); | ||
|
|
||
| // Clear every SoftReference<ThreadContext> that was ever stored into this ThreadLocal. | ||
|
headius marked this conversation as resolved.
|
||
| // This covers both the current thread and any adopted / pool threads that called into | ||
| // JRuby but are now idle and will never touch their ThreadLocalMap again. Without this, | ||
| // each such thread's ThreadLocalMap retains the chain: | ||
| // Thread (GC root) -> ThreadLocalMap -> SoftReference -> ThreadContext -> Ruby runtime | ||
| // which keeps the runtime (and its JRubyClassLoader with all loaded classes) alive until | ||
| // GC heap pressure forces soft-reference collection — which may never happen in a | ||
| // well-resourced long-lived process. (see https://github.com/jruby/jruby/issues/9092) | ||
| for (SoftReference<ThreadContext> ref : trackedRefs) { | ||
| ref.clear(); | ||
| } | ||
| trackedRefs.clear(); | ||
|
|
||
| // Also call remove() on the current thread so the (now-cleared) SoftReference object | ||
| // is expelled from this thread's ThreadLocalMap immediately rather than lazily. | ||
| remove(); | ||
| } | ||
|
|
||
| @Override | ||
| public void set(SoftReference<ThreadContext> value) { | ||
| super.set(value); | ||
| if (value != null) trackedRefs.add(value); | ||
| } | ||
|
|
||
| public void initMainThread() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| package org.jruby.embed; | ||
|
|
||
| import org.jruby.Ruby; | ||
| import org.junit.Test; | ||
|
|
||
| import java.lang.management.ManagementFactory; | ||
| import java.lang.ref.WeakReference; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
|
|
||
| import static org.junit.Assert.assertNull; | ||
| import static org.junit.Assert.assertTrue; | ||
|
|
||
| /** | ||
| * Regression test for https://github.com/jruby/jruby/issues/9092 | ||
| * | ||
| * Leak mechanism: | ||
| * Thread (GC root) → ThreadLocalMap → Entry.value = SoftRef<ThreadContext> | ||
| * → ThreadContext → Ruby → JRubyClassLoader → ~98 classes | ||
| * | ||
| * ThreadService extends ThreadLocal and stores a SoftReference<ThreadContext> per thread. | ||
| * SoftReferences are only cleared by the JVM under heap pressure, so in a well-resourced | ||
| * server System.gc() will NOT collect them. After the fix, teardown() explicitly calls | ||
| * SoftReference.clear() on every tracked ref, making the Ruby runtime weakly reachable | ||
| * and therefore collected by a normal System.gc() call. | ||
| */ | ||
| public class ScriptingContainerClassLeakTest { | ||
|
|
||
| /** | ||
| * Primary regression test for GH-9092. | ||
| * | ||
| * After the container is terminated (engine disposed), the Ruby runtime must | ||
| * be eligible for collection by a normal GC — not just when the JVM is under | ||
| * memory pressure. | ||
| * | ||
| * Without the fix: the calling thread's ThreadLocalMap retains | ||
| * Entry.value = SoftRef<ThreadContext> → ThreadContext → Ruby | ||
| * so Ruby remains <em>softly reachable</em> and survives System.gc(). | ||
| * | ||
| * With the fix: teardown() calls SoftReference.clear() on every tracked ref, | ||
| * breaking the chain; Ruby becomes only weakly reachable and is collected. | ||
| */ | ||
| @Test | ||
| public void runtimeIsGCdAfterTeardown() throws InterruptedException { | ||
| // Create engine and dispose inside a helper method so that the | ||
| // ScriptingContainer is guaranteed out-of-scope when we check GC. | ||
| WeakReference<Ruby> ref = createEvalAndClose(); | ||
| forceGC(); | ||
| assertNull( | ||
| "Ruby runtime was not collected after terminate() (GH-9092). " + | ||
| "The calling thread's ThreadLocalMap is holding a SoftReference to " + | ||
| "the runtime's ThreadContext, keeping it alive across GC cycles.", | ||
| ref.get() | ||
| ); | ||
| } | ||
|
|
||
| @Test | ||
| public void runtimeIsGCdAfterWorkerThreadTeardown() throws InterruptedException { | ||
| AtomicReference<WeakReference<Ruby>> refHolder = new AtomicReference<>(); | ||
|
|
||
| Thread thread = new Thread(() -> { | ||
| ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD); | ||
| Ruby runtime = sc.getProvider().getRuntime(); | ||
| refHolder.set(new WeakReference<>(runtime)); | ||
| sc.terminate(); | ||
| Ruby.clearGlobalRuntime(); | ||
| }); | ||
|
|
||
| thread.start(); | ||
| thread.join(); | ||
|
|
||
| WeakReference<Ruby> ref = refHolder.get(); | ||
| forceGC(); | ||
|
|
||
| assertNull( | ||
| "Ruby runtime created and terminated on a worker thread was not collected after terminate() (GH-9092).", | ||
| ref.get() | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Create a container, eval something (so the calling thread's ThreadContext | ||
| * is registered via ThreadService.set()), then dispose it. Returning only a | ||
| * WeakReference ensures the container itself is out-of-scope at the call site. | ||
| */ | ||
| private static WeakReference<Ruby> createEvalAndClose() { | ||
| ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD); | ||
| sc.runScriptlet("nil"); // triggers ThreadService.set() → SoftRef tracked | ||
| WeakReference<Ruby> ref = new WeakReference<>(sc.getProvider().getRuntime()); | ||
| sc.terminate(); | ||
| Ruby.clearGlobalRuntime(); | ||
| return ref; | ||
| // sc and all strong references it owns go out of scope here | ||
| } | ||
|
|
||
| /** | ||
| * Secondary check: loaded-class count must not grow linearly across cycles. | ||
| * This catches any classloader leak regardless of the specific mechanism. | ||
| */ | ||
| @Test | ||
| public void classCountDoesNotGrowUnboundedly() throws InterruptedException { | ||
| // Warmup | ||
| for (int i = 0; i < 3; i++) createAndTearDown(); | ||
| forceGC(); | ||
|
|
||
| long classesBefore = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount(); | ||
|
|
||
| for (int i = 0; i < 10; i++) createAndTearDown(); | ||
| forceGC(); | ||
|
|
||
| long classesAfter = ManagementFactory.getClassLoadingMXBean().getLoadedClassCount(); | ||
| long growth = classesAfter - classesBefore; | ||
|
|
||
| // Generous constant budget (lazy JDK internals etc.), but not per-cycle growth. | ||
| // Pre-fix behaviour was ~98 new classes per cycle * 10 = ~980. | ||
| assertTrue( | ||
| "Loaded class count grew by " + growth + " across 10 ScriptingContainer " + | ||
| "cycles (max allowed: 200). Classloader leak detected (see GH-9092).", | ||
| growth <= 200 | ||
| ); | ||
| } | ||
|
|
||
| // ------------------------------------------------------------------------- | ||
|
|
||
| private static void createAndTearDown() { | ||
| ScriptingContainer sc = new ScriptingContainer(LocalContextScope.SINGLETHREAD); | ||
| try { | ||
| sc.runScriptlet("1 + 1"); | ||
| } finally { | ||
| sc.terminate(); | ||
| Ruby.clearGlobalRuntime(); | ||
| } | ||
| } | ||
|
|
||
| @SuppressWarnings({"deprecation", "removal"}) | ||
| private static void forceGC() throws InterruptedException { | ||
| // ScriptingContainer has finalize(), so two GC passes are needed: | ||
| // 1st: detects sc is unreachable → enqueues for finalization | ||
| // System.runFinalization(): runs sc.finalize() → sc now truly collectable | ||
| // 2nd: collects sc + LocalContext → Ruby loses its strong-ref path | ||
| // 3rd+: Ruby (with fix: weakly reachable) is collected | ||
| for (int i = 0; i < 5; i++) { | ||
| System.gc(); | ||
| System.runFinalization(); | ||
| Thread.sleep(100); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is rejected, except for reordering the teardown sequence. LoadService does indeed call getCurrentContext, but it can simply be moved above the ThreadService teardown above. The order of the ThreadService and classloader termination is unimportant, because neither of them depends upon the other.
See #9359