This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karaf.git
The following commit(s) were added to refs/heads/main by this push:
new 9b4a09aaf fix(#734): build the type converter registry once, and say
when it is discarded (#739)
9b4a09aaf is described below
commit 9b4a09aafff8f30922ac2e170646601bdbe73640
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Sep 1 21:23:39 2026 +0200
fix(#734): build the type converter registry once, and say when it is
discarded (#739)
* fix(#734): build the type converter registry once, and say when it is
discarded
getDelegate() checked the volatile delegate for null and assigned it
without holding a lock. The volatile makes the read safe but not the
check-and-assign, so two threads racing on first access each built a
registry; one was returned, the other silently dropped along with
anything registered on it. Use double checked locking so the conversion
hot path stays lock free while first access happens once.
removedService and addingService now take the same lock, so a delegate
cannot be stopped and nulled while another thread is handing it out.
removedService also logs at WARN when it discards the delegate.
createRegistry replays the core converters and the loaders the tracker
still holds, but it cannot replay converters registered programmatically
via addTypeConverter or a Blueprint bean implementing TypeConverters, so
a single loader going away silently removes them from a running context.
Surfacing it does not fix the loss, but it stops it being invisible.
The asymmetry with addingService is deliberate and is left alone:
d54f9a806 (#625, PR #684) reverted invalidate-on-add to loading into the
existing delegate precisely to preserve programmatic registrations.
Actually replaying them on rebuild means recording them as they are
added, which is a larger design change than this.
The concurrency test fails 3 out of 3 runs against the previous
getDelegate and passes against this one, so it pins the behaviour rather
than being a flaky probe.
* fix(#734): address review - drop the tracker callback locking and replay
programmatic converters
Review feedback from jbonofre on #739.
Locking. addingService and removedService are no longer synchronized, and
createRegistry no longer calls back into the ServiceTracker at all: the
callbacks maintain a ConcurrentHashMap of the loaders the tracker hands us,
and the rebuild iterates a sorted snapshot of that map. Nothing calls into
the tracker or the framework while holding this instance's monitor any more,
so there is no lock ordering left to get wrong, and a slow loader.load() no
longer blocks every conversion in the container.
getDelegate is now plainly synchronized rather than double checked. Note
this
narrows but does not close the window in which a delegate handed to a caller
is stopped by removedService; that race predates this change and closing it
means not stopping the outgoing delegate eagerly.
Service use count. addingService takes the service, so releasing it is ours.
It now ungets on the failure path - the tracker treats a customizer that
throws as never tracked, so removedService would never run for that
reference
- and removedService ungets on the normal path, which leaked just as much.
Both leaks predate this PR.
Converter loss. Rather than only warning about it, registrations made
through
this facade (addTypeConverter, addTypeConverters, addBulkTypeConverters,
addFallbackTypeConverter, addConverter, removeTypeConverter, and the
setInjector / setTypeConverterExists / setTypeConverterExistsLoggingLevel
setters, which were lost the same way) are recorded in order and replayed
onto
the rebuilt registry. Removals replay too, so a rebuild reproduces the
sequence instead of resurrecting a converter that was deliberately taken
out.
Partial removal was the alternative, but attributing a converter to the
loader
that contributed it is not something TypeConverterRegistry exposes, and it
would undo the invalidate-and-rebuild strategy from d54f9a806 (#625).
The warning stays, reworded to say what actually happens, and is suppressed
while the context is stopping so tracker.close() no longer logs one per
loader
on shutdown.
Six new tests; four of them fail against the previous commit.
addingServiceMustNotHoldTheInstanceMonitor is a forward guard rather than a
regression test - the old lock free read in getDelegate meant it passed
there
too.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(#734): address second review - key the replay collection and make
registration atomic
Second review round from jbonofre on #739. He confirmed the AbstractTracked
correction and both ungetService fixes, and found four blocking items by
writing probe tests against the previous head. Three of them are fixed here;
the fourth is going to a follow-up issue at his suggestion.
register() and removeTypeConverter() are synchronized, on the monitor
getDelegate() already takes. Applying a registration and recording it had to
become one step: a rebuild landing between the two halves would replay a
collection the registration was not in yet and then hand back a registry it
had
never been applied to, so the converter would be recorded but absent - the
same
loss this PR exists to prevent, one level down. The monitor is reentrant, so
the nested getDelegate() costs nothing.
The replay collection is now keyed and insertion ordered rather than an
append-only list. The key is what the registration is about: a
TypeConvertible
for a converter, the contributed instance for the bulk, fallback and
addTypeConverters forms, a sentinel per setter. Re-registering replaces
instead
of appending, which is what a Blueprint container refresh does, and
removeTypeConverter deletes the matching entry instead of appending an
inverse.
The inverse reproduced the right end state on replay but kept the removed
converter - and the classloader of the bundle that contributed it - strongly
reachable for the life of the context, which is the leak shape that matters
in
OSGi. 500 add/remove pairs left 1000 entries before this; they leave none
now.
A removal for a pair that was never registered is no longer retained at all.
doStop() clears the collection. ServiceSupport permits stop then start, and
this is a context scoped service, so without it a restart replayed the
previous
lifecycle's registrations - including converters owned by bundles that are
gone
by the time the context comes back up.
Five new tests, sixteen in the class. Four of the five fail against the
previous commit, addRemovePairsDoNotAccumulate at exactly the 1000 entries
the
review reported. registrationCannotInterleaveWithARebuild passes there too,
so
it is a forward guard rather than a regression test: the old register()
still
reached a synchronized getDelegate(), so it blocked anyway. Reproducing the
actual interleave needs a registry that can be stalled inside its
addTypeConverter, as the review did with a throwaway.
Not fixed here, going to a follow-up issue: removedService writes delegate
outside the monitor, so an invalidation can be swallowed by a rebuild that
is
already in flight, and the wider monitor makes that window bigger rather
than
smaller. It is the same underlying problem as the eager stop of a delegate
callers already hold, and both belong in one issue.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../apache/camel/karaf/core/OsgiTypeConverter.java | 182 ++++++++++++---
.../camel/karaf/core/OsgiTypeConverterTest.java | 254 +++++++++++++++++++++
2 files changed, 407 insertions(+), 29 deletions(-)
diff --git
a/core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java
b/core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java
index 2aa75037c..b177e7074 100644
---
a/core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java
+++
b/core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java
@@ -20,8 +20,12 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
@@ -57,6 +61,32 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
private CamelContext camelContext;
private final Injector injector;
private final ServiceTracker<TypeConverterLoader, Object> tracker;
+ /**
+ * The loaders the tracker has handed us, kept here rather than read back
from the tracker: resolving them
+ * through the tracker inside {@link #createRegistry()} would mean calling
into the ServiceTracker and the
+ * framework while holding this instance's monitor.
+ */
+ private final Map<ServiceReference<TypeConverterLoader>,
TypeConverterLoader> trackedLoaders
+ = new ConcurrentHashMap<>();
+ /**
+ * Registrations made through this facade rather than by a {@link
TypeConverterLoader}, so a rebuilt registry can
+ * be brought back to the same state. Discarding the delegate would
otherwise drop them with no way to get them
+ * back.
+ * <p/>
+ * Keyed rather than a plain list, and insertion ordered so replay keeps
the original order. The key is what the
+ * registration is <em>about</em> - a {@link TypeConvertible} for a
converter, the contributed instance for the
+ * bulk and fallback forms, a sentinel for each setter - so re-registering
replaces instead of appending, and
+ * removing deletes the entry instead of appending an inverse. Both matter
in OSGi: each registration strongly
+ * references the converter it captured, and through it the classloader of
the bundle that contributed it, so a
+ * collection that only ever grows pins bundles that have long since been
uninstalled.
+ * <p/>
+ * Guarded by this instance's monitor, the same one {@link #getDelegate()}
takes.
+ */
+ private final Map<Object, Consumer<TypeConverterRegistry>>
programmaticRegistrations = new LinkedHashMap<>();
+
+ private static final Object INJECTOR_KEY = new Object();
+ private static final Object TYPE_CONVERTER_EXISTS_KEY = new Object();
+ private static final Object TYPE_CONVERTER_EXISTS_LOGGING_LEVEL_KEY = new
Object();
private volatile DefaultTypeConverter delegate;
private volatile boolean trackerOpened;
@@ -67,27 +97,36 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
this.tracker = new ServiceTracker<>(bundleContext,
TypeConverterLoader.class.getName(), this);
}
- private void ensureTrackerOpen() {
+ private synchronized void ensureTrackerOpen() {
if (!trackerOpened) {
tracker.open();
trackerOpened = true;
}
}
+ // deliberately not synchronized: the tracker calls this from the
framework's service event dispatch, and
+ // taking this instance's monitor here would put our lock on the far side
of the framework's, which is the
+ // ordering that makes a lock inversion possible
@Override
public Object addingService(ServiceReference<TypeConverterLoader>
serviceReference) {
LOG.trace("AddingService: {}, Bundle: {}", serviceReference,
serviceReference.getBundle());
TypeConverterLoader loader =
bundleContext.getService(serviceReference);
if (loader != null) {
+ trackedLoaders.put(serviceReference, loader);
try {
LOG.debug("loading type converter from bundle: {}",
serviceReference.getBundle().getSymbolicName());
- if (delegate != null) {
+ DefaultTypeConverter current = delegate;
+ if (current != null) {
// load the converter directly into the existing delegate
to preserve
// any converters that were added programmatically (e.g.
via Blueprint beans
// implementing TypeConverters)
- loader.load(delegate);
+ loader.load(current);
}
} catch (Throwable t) {
+ // the tracker treats a customizer that throws as "never
tracked", so it will not call
+ // removedService for this reference and nothing else will
release the use count taken above
+ trackedLoaders.remove(serviceReference);
+ ungetQuietly(serviceReference);
throw new RuntimeCamelException("Error loading type converters
from service: " + serviceReference + " due: " + t.getMessage(), t);
}
}
@@ -99,9 +138,21 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
public void modifiedService(ServiceReference<TypeConverterLoader>
serviceReference, Object o) {
}
+ // not synchronized, for the same reason as addingService
@Override
public void removedService(ServiceReference<TypeConverterLoader>
serviceReference, Object o) {
LOG.trace("RemovedService: {}, Bundle: {}", serviceReference,
serviceReference.getBundle());
+ trackedLoaders.remove(serviceReference);
+ // we took the service in addingService, so releasing it is ours to do
+ ungetQuietly(serviceReference);
+ if (this.delegate != null && !isStopping() && !isStopped()) {
+ // worth saying out loud: one loader going away discards the whole
registry, and the rebuild is a
+ // full reload of the core converters plus every remaining loader,
not an incremental removal
+ LOG.warn("TypeConverterLoader from bundle {} was unregistered,
discarding the type converter registry;"
+ + " it is rebuilt on next use from the remaining loaders,
and the converters registered"
+ + " programmatically on this context are replayed onto
the rebuilt registry.",
+ serviceReference.getBundle() != null ?
serviceReference.getBundle().getSymbolicName() : serviceReference);
+ }
try {
ServiceHelper.stopService(this.delegate);
} catch (Exception e) {
@@ -112,6 +163,16 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
this.delegate = null;
}
+ private void ungetQuietly(ServiceReference<TypeConverterLoader>
serviceReference) {
+ try {
+ bundleContext.ungetService(serviceReference);
+ } catch (Exception e) {
+ // the bundle or the framework may already be gone; releasing the
use count is best effort
+ LOG.debug("Error ungetting service {} due: {}. This exception will
be ignored.", serviceReference,
+ e.getMessage(), e);
+ }
+ }
+
@Override
protected void doStart() throws Exception {
ensureTrackerOpen();
@@ -121,6 +182,14 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
protected void doStop() throws Exception {
this.tracker.close();
this.trackerOpened = false;
+ // close() calls removedService for everything still tracked, this
only makes the end state explicit
+ this.trackedLoaders.clear();
+ synchronized (this) {
+ // ServiceSupport allows stop then start again, and this is a
context scoped service, so without this a
+ // restart would replay the previous lifecycle's registrations -
including converters belonging to
+ // bundles that are gone by the time the context comes back up
+ this.programmaticRegistrations.clear();
+ }
ServiceHelper.stopService(this.delegate);
this.delegate = null;
}
@@ -173,27 +242,33 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
@Override
public void addTypeConverter(Class<?> toType, Class<?> fromType,
TypeConverter typeConverter) {
- getDelegate().addTypeConverter(toType, fromType, typeConverter);
+ register(new TypeConvertible<>(fromType, toType),
+ registry -> registry.addTypeConverter(toType, fromType,
typeConverter));
}
@Override
public void addTypeConverters(Object typeConverters) {
- getDelegate().addTypeConverters(typeConverters);
+ register(typeConverters, registry ->
registry.addTypeConverters(typeConverters));
}
@Override
public void addBulkTypeConverters(BulkTypeConverters bulkTypeConverters) {
- getDelegate().addBulkTypeConverters(bulkTypeConverters);
+ register(bulkTypeConverters, registry ->
registry.addBulkTypeConverters(bulkTypeConverters));
}
@Override
- public boolean removeTypeConverter(Class<?> toType, Class<?> fromType) {
- return getDelegate().removeTypeConverter(toType, fromType);
+ public synchronized boolean removeTypeConverter(Class<?> toType, Class<?>
fromType) {
+ boolean removed = getDelegate().removeTypeConverter(toType, fromType);
+ // delete the matching registration rather than recording an inverse.
An inverse would reproduce the same
+ // end state on replay, but it would also keep the removed converter -
and its bundle's classloader -
+ // strongly reachable for the life of the context, and cost a no-op
call on every future rebuild
+ programmaticRegistrations.remove(new TypeConvertible<>(fromType,
toType));
+ return removed;
}
@Override
public void addFallbackTypeConverter(TypeConverter typeConverter, boolean
canPromote) {
- getDelegate().addFallbackTypeConverter(typeConverter, canPromote);
+ register(typeConverter, registry ->
registry.addFallbackTypeConverter(typeConverter, canPromote));
}
@Override
@@ -203,7 +278,7 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
@Override
public void setInjector(Injector injector) {
- getDelegate().setInjector(injector);
+ register(INJECTOR_KEY, registry -> registry.setInjector(injector));
}
@Override
@@ -228,7 +303,8 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
@Override
public void setTypeConverterExistsLoggingLevel(LoggingLevel loggingLevel) {
- getDelegate().setTypeConverterExistsLoggingLevel(loggingLevel);
+ register(TYPE_CONVERTER_EXISTS_LOGGING_LEVEL_KEY,
+ registry ->
registry.setTypeConverterExistsLoggingLevel(loggingLevel));
}
@Override
@@ -238,10 +314,13 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
@Override
public void setTypeConverterExists(TypeConverterExists
typeConverterExists) {
- getDelegate().setTypeConverterExists(typeConverterExists);
+ register(TYPE_CONVERTER_EXISTS_KEY, registry ->
registry.setTypeConverterExists(typeConverterExists));
}
- public DefaultTypeConverter getDelegate() {
+ // fully synchronized rather than double checked: the delegate is not
immutable after publication -
+ // removedService stops and replaces it - so a lock free read of the field
buys a race for no real gain,
+ // conversion work dwarfing an uncontended monitor either way
+ public synchronized DefaultTypeConverter getDelegate() {
if (delegate == null) {
// ensure the tracker is open so we can discover
TypeConverterLoader services
// before creating the registry - this is important because
getDelegate() may be
@@ -279,28 +358,73 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
throw new RuntimeCamelException("Error loading CoreTypeConverter
due: " + e.getMessage(), e);
}
- // Load the type converters the tracker has been tracking
- // Here we need to use the ServiceReference to check the ranking
- ServiceReference<TypeConverterLoader>[] serviceReferences =
this.tracker.getServiceReferences();
- if (serviceReferences != null) {
- ArrayList<ServiceReference<TypeConverterLoader>> servicesList =
- new ArrayList<>(Arrays.asList(serviceReferences));
- // Just make sure we install the high ranking fallback converter
at last
- Collections.sort(servicesList);
- for (ServiceReference<TypeConverterLoader> sr : servicesList) {
- try {
- LOG.debug("loading type converter from bundle: {}",
sr.getBundle().getSymbolicName());
-
((TypeConverterLoader)this.tracker.getService(sr)).load(answer);
- } catch (Throwable t) {
- throw new RuntimeCamelException("Error loading type
converters from service: " + sr + " due: " + t.getMessage(), t);
- }
+ // Load the type converters the tracker has been tracking. These come
from our own map rather than from
+ // tracker.getServiceReferences()/getService(): this runs while
holding this instance's monitor, and
+ // calling back into the tracker from here is what would establish a
lock ordering against the framework.
+ List<ServiceReference<TypeConverterLoader>> servicesList = new
ArrayList<>(trackedLoaders.keySet());
+ // Just make sure we install the high ranking fallback converter at
last
+ Collections.sort(servicesList);
+ for (ServiceReference<TypeConverterLoader> sr : servicesList) {
+ TypeConverterLoader loader = trackedLoaders.get(sr);
+ if (loader == null) {
+ // unregistered between the snapshot and here
+ continue;
+ }
+ try {
+ LOG.debug("loading type converter from bundle: {}",
sr.getBundle().getSymbolicName());
+ loader.load(answer);
+ } catch (Throwable t) {
+ throw new RuntimeCamelException("Error loading type converters
from service: " + sr + " due: " + t.getMessage(), t);
}
}
+ replayProgrammaticRegistrations(answer);
+
LOG.trace("Created TypeConverter: {}", answer);
return answer;
}
+ /**
+ * Re-applies everything that was registered through this facade rather
than by a
+ * {@link TypeConverterLoader}, in the order it was originally applied.
+ */
+ private void replayProgrammaticRegistrations(DefaultTypeConverter
registry) {
+ if (programmaticRegistrations.isEmpty()) {
+ return;
+ }
+ LOG.debug("Replaying {} programmatic registration(s) onto the rebuilt
type converter registry",
+ programmaticRegistrations.size());
+ for (Consumer<TypeConverterRegistry> registration :
programmaticRegistrations.values()) {
+ registration.accept(registry);
+ }
+ }
+
+ /**
+ * Applies a registration to the current delegate and remembers it under
{@code key}, so that discarding the
+ * delegate does not discard the registration with it.
+ * <p/>
+ * Synchronized, and on the same monitor {@link #getDelegate()} takes:
applying and recording have to be one
+ * step. A rebuild landing between them would replay a list this
registration is not in yet and then hand back
+ * a registry it was never applied to, so the converter would be recorded
but not actually present - the same
+ * loss this class is meant to prevent, one level down. The monitor is
reentrant, so the nested
+ * {@link #getDelegate()} is free.
+ */
+ /**
+ * How many programmatic registrations are currently retained for replay.
Package private for the tests, which
+ * pin the invariant that this does not grow without bound - each entry
retains the converter it captured, and
+ * with it the classloader of the contributing bundle.
+ */
+ synchronized int programmaticRegistrationCount() {
+ return programmaticRegistrations.size();
+ }
+
+ private synchronized void register(Object key,
Consumer<TypeConverterRegistry> registration) {
+ // apply first: a registration the delegate rejects is not one worth
replaying. getDelegate() may build the
+ // registry here, replaying the map as it stands - this entry goes in
after, so it cannot be applied twice
+ registration.accept(getDelegate());
+ programmaticRegistrations.put(key, registration);
+ }
+
private class OsgiDefaultTypeConverter extends DefaultTypeConverter {
public OsgiDefaultTypeConverter(PackageScanClassResolver resolver,
Injector injector, boolean loadTypeConverters,
@@ -332,7 +456,7 @@ public class OsgiTypeConverter extends ServiceSupport
implements TypeConverter,
@Override
public void addConverter(TypeConvertible<?, ?> typeConvertible,
TypeConverter typeConverter) {
- getDelegate().addConverter(typeConvertible, typeConverter);
+ register(typeConvertible, registry ->
registry.addConverter(typeConvertible, typeConverter));
}
}
\ No newline at end of file
diff --git
a/core/camel-core-osgi/src/test/java/org/apache/camel/karaf/core/OsgiTypeConverterTest.java
b/core/camel-core-osgi/src/test/java/org/apache/camel/karaf/core/OsgiTypeConverterTest.java
index 838193400..92f9b89c4 100644
---
a/core/camel-core-osgi/src/test/java/org/apache/camel/karaf/core/OsgiTypeConverterTest.java
+++
b/core/camel-core-osgi/src/test/java/org/apache/camel/karaf/core/OsgiTypeConverterTest.java
@@ -17,7 +17,20 @@
package org.apache.camel.karaf.core;
import org.apache.camel.CamelContext;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.TypeConverter;
import org.apache.camel.spi.Injector;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.impl.converter.DefaultTypeConverter;
import org.apache.camel.spi.TypeConverterLoader;
import org.apache.camel.spi.TypeConverterRegistry;
import org.junit.jupiter.api.BeforeEach;
@@ -46,6 +59,8 @@ public class OsgiTypeConverterTest {
@Mock
private TypeConverterLoader loader;
@Mock
+ private TypeConverter typeConverter;
+ @Mock
private Bundle bundle;
private OsgiTypeConverter osgiTypeConverter;
@@ -110,4 +125,243 @@ public class OsgiTypeConverterTest {
var delegateAfter = osgiTypeConverter.getDelegate();
assertNotNull(delegateAfter);
}
+
+ @Test
+ void concurrentFirstAccessShouldBuildTheRegistryOnce() throws Exception {
+ int threads = 16;
+ AtomicInteger created = new AtomicInteger();
+ CountDownLatch startLine = new CountDownLatch(1);
+
+ OsgiTypeConverter counting = new OsgiTypeConverter(bundleContext,
camelContext, injector) {
+ @Override
+ protected DefaultTypeConverter createRegistry() {
+ created.incrementAndGet();
+ return super.createRegistry();
+ }
+ };
+
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try {
+ List<Future<DefaultTypeConverter>> futures = new ArrayList<>();
+ for (int i = 0; i < threads; i++) {
+ futures.add(pool.submit(() -> {
+ startLine.await();
+ return counting.getDelegate();
+ }));
+ }
+ // release them all at once so they race on the null check
+ startLine.countDown();
+
+ DefaultTypeConverter first = futures.get(0).get(30,
TimeUnit.SECONDS);
+ assertNotNull(first);
+ for (Future<DefaultTypeConverter> f : futures) {
+ assertSame(first, f.get(30, TimeUnit.SECONDS),
+ "every caller must see the same registry instance");
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+
+ assertEquals(1, created.get(),
+ "the registry must be built exactly once, otherwise converters
registered on a discarded"
+ + " instance are silently lost");
+ }
+
+ /** Marker source type, so the registered converter cannot collide with a
core one. */
+ interface Marker {
+ }
+
+ @Test
+ void rebuiltRegistryReloadsTheTrackedLoadersWithoutAskingTheTracker()
throws Exception {
+ // arrives before the registry exists, so addingService only records it
+ osgiTypeConverter.addingService(serviceReference);
+ verify(loader, never()).load(any());
+
+ DefaultTypeConverter first = osgiTypeConverter.getDelegate();
+
+ // createRegistry replayed it from the recorded loaders; it never
called back into the ServiceTracker,
+ // which is what would put a framework call underneath this instance's
monitor
+ verify(loader).load(first);
+ }
+
+ @Test
+ void programmaticConverterSurvivesARegistryRebuild() throws Exception {
+ DefaultTypeConverter before = osgiTypeConverter.getDelegate();
+ osgiTypeConverter.addTypeConverter(String.class, Marker.class,
typeConverter);
+ assertNotNull(before.lookup(String.class, Marker.class),
"precondition: the converter is registered");
+
+ // one loader going away discards the whole registry
+ osgiTypeConverter.removedService(serviceReference, loader);
+ DefaultTypeConverter after = osgiTypeConverter.getDelegate();
+
+ assertNotSame(before, after, "the registry should have been rebuilt");
+ assertNotNull(after.lookup(String.class, Marker.class),
+ "a converter registered programmatically must be replayed onto
the rebuilt registry, otherwise it"
+ + " disappears from a running context when any bundle
unregisters a loader");
+ }
+
+ @Test
+ void removedTypeConverterIsNotResurrectedByARebuild() throws Exception {
+ osgiTypeConverter.addTypeConverter(String.class, Marker.class,
typeConverter);
+ osgiTypeConverter.removeTypeConverter(String.class, Marker.class);
+
+ osgiTypeConverter.removedService(serviceReference, loader);
+
+ assertNull(osgiTypeConverter.getDelegate().lookup(String.class,
Marker.class),
+ "the replay must reproduce the sequence, not just the
additions");
+ }
+
+ @Test
+ void addingServiceReleasesTheServiceWhenLoadingFails() throws Exception {
+ osgiTypeConverter.getDelegate();
+ doThrow(new RuntimeException("boom")).when(loader).load(any());
+
+ assertThrows(RuntimeCamelException.class, () ->
osgiTypeConverter.addingService(serviceReference));
+
+ // a customizer that throws is treated as never tracked, so
removedService will not run for this
+ // reference and nothing else would release the use count taken by
addingService
+ verify(bundleContext).ungetService(serviceReference);
+ }
+
+ @Test
+ void removedServiceReleasesTheService() {
+ osgiTypeConverter.addingService(serviceReference);
+
+ osgiTypeConverter.removedService(serviceReference, loader);
+
+ verify(bundleContext).ungetService(serviceReference);
+ }
+
+ @Test
+ void addingServiceMustNotHoldTheInstanceMonitor() throws Exception {
+ // build first, so addingService takes the branch that calls into the
loader
+ osgiTypeConverter.getDelegate();
+
+ CountDownLatch insideLoad = new CountDownLatch(1);
+ CountDownLatch releaseLoad = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ insideLoad.countDown();
+ releaseLoad.await(30, TimeUnit.SECONDS);
+ return null;
+ }).when(loader).load(any());
+
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ Future<?> adding = pool.submit(() ->
osgiTypeConverter.addingService(serviceReference));
+ assertTrue(insideLoad.await(30, TimeUnit.SECONDS), "addingService
should have reached loader.load");
+
+ // the framework calls addingService while it is dispatching a
service event; if it took this
+ // instance's monitor, every conversion in the container would
block behind an arbitrary bundle's
+ // loader for as long as that loader takes
+ Future<DefaultTypeConverter> reader =
pool.submit(osgiTypeConverter::getDelegate);
+ assertNotNull(reader.get(10, TimeUnit.SECONDS),
+ "getDelegate must not be blocked by an in-flight
addingService");
+
+ releaseLoad.countDown();
+ adding.get(30, TimeUnit.SECONDS);
+ } finally {
+ releaseLoad.countDown();
+ pool.shutdownNow();
+ }
+ }
+
+ @Test
+ void addRemovePairsDoNotAccumulate() {
+ osgiTypeConverter.getDelegate();
+
+ for (int i = 0; i < 500; i++) {
+ osgiTypeConverter.addTypeConverter(String.class, Marker.class,
typeConverter);
+ osgiTypeConverter.removeTypeConverter(String.class, Marker.class);
+ }
+
+ // an inverse-appending list would sit at 1000 here, and every
converter it captured - and the classloader
+ // of the bundle that contributed it - would stay strongly reachable
for the life of the context
+ assertEquals(0, osgiTypeConverter.programmaticRegistrationCount(),
+ "add/remove pairs must prune, not accumulate");
+ }
+
+ @Test
+ void reRegisteringTheSameConversionReplaces() {
+ osgiTypeConverter.getDelegate();
+
+ for (int i = 0; i < 10; i++) {
+ // what a Blueprint container refresh looks like: the same
conversion registered again
+ osgiTypeConverter.addTypeConverter(String.class, Marker.class,
typeConverter);
+ }
+
+ assertEquals(1, osgiTypeConverter.programmaticRegistrationCount(),
+ "re-registering the same conversion must replace rather than
append");
+ }
+
+ @Test
+ void removingAConversionThatWasNeverRegisteredDoesNotAccumulate() {
+ osgiTypeConverter.getDelegate();
+
+ osgiTypeConverter.removeTypeConverter(String.class, Marker.class);
+
+ assertEquals(0, osgiTypeConverter.programmaticRegistrationCount(),
+ "a removal for a pair that was never registered must not be
retained");
+ }
+
+ @Test
+ void restartDoesNotReplayThePreviousLifecycle() throws Exception {
+ osgiTypeConverter.start();
+ osgiTypeConverter.addTypeConverter(String.class, Marker.class,
typeConverter);
+ assertNotNull(osgiTypeConverter.getDelegate().lookup(String.class,
Marker.class));
+
+ osgiTypeConverter.stop();
+ osgiTypeConverter.start();
+
+ // the converters captured before the stop belong to bundles that may
be gone by now
+ assertEquals(0, osgiTypeConverter.programmaticRegistrationCount());
+ assertNull(osgiTypeConverter.getDelegate().lookup(String.class,
Marker.class),
+ "a stop/start cycle must not resurrect the previous
lifecycle's registrations");
+ }
+
+ @Test
+ void registrationCannotInterleaveWithARebuild() throws Exception {
+ CountDownLatch insideBuild = new CountDownLatch(1);
+ CountDownLatch releaseBuild = new CountDownLatch(1);
+
+ OsgiTypeConverter stalling = new OsgiTypeConverter(bundleContext,
camelContext, injector) {
+ @Override
+ protected DefaultTypeConverter createRegistry() {
+ DefaultTypeConverter built = super.createRegistry();
+ insideBuild.countDown();
+ try {
+ releaseBuild.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return built;
+ }
+ };
+
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ Future<DefaultTypeConverter> builder =
pool.submit(stalling::getDelegate);
+ assertTrue(insideBuild.await(30, TimeUnit.SECONDS), "the rebuild
should have started");
+
+ Future<?> registrar = pool.submit(() -> {
+ stalling.addTypeConverter(String.class, Marker.class,
typeConverter);
+ return null;
+ });
+
+ // apply-and-record has to be one step against the rebuild. If it
were not, this registration would be
+ // applied to the registry being discarded and only recorded
afterwards, so the rebuilt one would
+ // neither have it applied nor replay it
+ assertThrows(TimeoutException.class, () -> registrar.get(2,
TimeUnit.SECONDS),
+ "a registration must not proceed while a rebuild holds the
monitor");
+
+ releaseBuild.countDown();
+ builder.get(30, TimeUnit.SECONDS);
+ registrar.get(30, TimeUnit.SECONDS);
+
+ assertNotNull(stalling.getDelegate().lookup(String.class,
Marker.class),
+ "the registration must land on the registry that is
actually live");
+ } finally {
+ releaseBuild.countDown();
+ pool.shutdownNow();
+ }
+ }
}