From 2363b37ff57bf935e3f98758c960986192738f0e Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Sat, 14 Feb 2026 11:15:13 -0800 Subject: [PATCH] Capture context class loader during async callback registration --- .../internal/state/CallbackRegistration.java | 8 +++ .../state/CallbackRegistrationTest.java | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistration.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistration.java index 5ac06c76799..b603de7fc21 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistration.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistration.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; +import javax.annotation.Nullable; /** * A registered callback. @@ -29,11 +30,13 @@ public final class CallbackRegistration { private final Runnable callback; private final List instrumentDescriptors; private final boolean hasStorages; + @Nullable private final ClassLoader contextClassLoader; private CallbackRegistration( List observableMeasurements, Runnable callback) { this.observableMeasurements = observableMeasurements; this.callback = callback; + this.contextClassLoader = Thread.currentThread().getContextClassLoader(); this.instrumentDescriptors = observableMeasurements.stream() .map(SdkObservableMeasurement::getInstrumentDescriptor) @@ -80,6 +83,10 @@ public void invokeCallback(RegisteredReader reader, long startEpochNanos, long e observableMeasurements.forEach( observableMeasurement -> observableMeasurement.setActiveReader(reader, startEpochNanos, epochNanos)); + // Restore the context class loader that was active when the callback was registered. + Thread currentThread = Thread.currentThread(); + ClassLoader previousContextClassLoader = currentThread.getContextClassLoader(); + currentThread.setContextClassLoader(contextClassLoader); try { callback.run(); } catch (Throwable e) { @@ -87,6 +94,7 @@ public void invokeCallback(RegisteredReader reader, long startEpochNanos, long e throttlingLogger.log( Level.WARNING, "An exception occurred invoking callback for " + this + ".", e); } finally { + currentThread.setContextClassLoader(previousContextClassLoader); observableMeasurements.forEach(SdkObservableMeasurement::unsetActiveReader); } } diff --git a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistrationTest.java b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistrationTest.java index 80d02c90ff9..c54466a0603 100644 --- a/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistrationTest.java +++ b/sdk/metrics/src/test/java/io/opentelemetry/sdk/metrics/internal/state/CallbackRegistrationTest.java @@ -30,6 +30,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -219,6 +220,59 @@ void invokeCallback_NoStorage() { assertThat(counter.get()).isEqualTo(0); } + @Test + void invokeCallback_RestoresContextClassLoader() { + // Simulate the context class loader at registration time + ClassLoader registrationClassLoader = new ClassLoader() {}; + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + + Thread.currentThread().setContextClassLoader(registrationClassLoader); + AtomicReference observedClassLoader = new AtomicReference<>(); + Runnable callback = + () -> observedClassLoader.set(Thread.currentThread().getContextClassLoader()); + CallbackRegistration callbackRegistration = + CallbackRegistration.create(Collections.singletonList(measurement2), callback); + Thread.currentThread().setContextClassLoader(originalClassLoader); + + // Simulate invocation on a thread with null context class loader (like DaemonThreadFactory) + Thread.currentThread().setContextClassLoader(null); + callbackRegistration.invokeCallback(registeredReader, 0, 1); + + // Callback should have seen the registration-time classloader + assertThat(observedClassLoader.get()).isSameAs(registrationClassLoader); + + // After invocation, the thread's context classloader should be restored to null + assertThat(Thread.currentThread().getContextClassLoader()).isNull(); + + // Clean up + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + + @Test + void invokeCallback_RestoresContextClassLoaderOnException() { + ClassLoader registrationClassLoader = new ClassLoader() {}; + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + + Thread.currentThread().setContextClassLoader(registrationClassLoader); + Runnable callback = + () -> { + throw new RuntimeException("Error!"); + }; + CallbackRegistration callbackRegistration = + CallbackRegistration.create(Collections.singletonList(measurement2), callback); + Thread.currentThread().setContextClassLoader(originalClassLoader); + + // Simulate invocation on a thread with null context class loader + Thread.currentThread().setContextClassLoader(null); + callbackRegistration.invokeCallback(registeredReader, 0, 1); + + // Context classloader should still be restored even after exception + assertThat(Thread.currentThread().getContextClassLoader()).isNull(); + + // Clean up + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + @Test void invokeCallback_MultipleMeasurements_ThrowsException() { Runnable callback =