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
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@
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;
Expand Down Expand Up @@ -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;

Expand All @@ -67,27 +97,36 @@ public OsgiTypeConverter(BundleContext bundleContext, CamelContext camelContext,
this.tracker = new ServiceTracker<>(bundleContext, TypeConverterLoader.class.getName(), this);
}

private void ensureTrackerOpen() {
private synchronized void ensureTrackerOpen() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure it's actually required as the bundle activator is itself sync.
It's not a big deal to add this, but not sure it will actually do much.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept, but for a narrower reason than the original commit implied — you are right that the activator's own serialisation covers the common path.

ensureTrackerOpen() is reachable from both doStart() and getDelegate(), and trackerOpened is a check-then-act, so two threads arriving together could both call tracker.open(). It is cheap insurance rather than something the activator guarantees.

What changed is that it is now safe to hold: addingService / removedService no longer take this monitor, so tracker.open() firing trackInitial() synchronously from inside the lock cannot come back round and block on it.

Claude Code on behalf of Andrea Cosentino

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureTrackerOpen() is now synchronized on this, but doStop() closes the tracker and clears trackerOpened without that monitor — and getDelegate() has no lifecycle guard, so the tracker can be left open after stop, permanently.

BaseService.stop() runs doStop() under its own ReentrantLock, not under this, so the two are not mutually excluded. Interleaving: T1 in doStop() executes tracker.close(); T2 inside synchronized getDelegate() -> ensureTrackerOpen() calls tracker.open(); T1 then sets trackerOpened = false. Result: a live, listening ServiceTracker on a STOPPED service, holding a getService() use count for every TypeConverterLoader.

Nothing can clean that up — BaseService.stop() returns early on if (status == STOPPED || status == SHUTTING_DOWN || status == SHUTDOWN), so a second stop() never reaches doStop().

The same hole exists with no race at all: getDelegate() performs no isStopped() / isStopping() check, so one stray conversion after stop calls ensureTrackerOpen() -> tracker.open() and resurrects the tracker for the life of the framework, pinning every contributing bundle's classloader. Synchronizing one half of a two-field invariant is worse than neither, because it reads as protected. restartDoesNotReplayThePreviousLifecycle shows the lifecycle was considered, but only the explicit stop/start path.

if (!trackerOpened) {
tracker.open();
trackerOpened = true;
}
}
Comment thread
jbonofre marked this conversation as resolved.

// 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) {

@jbonofre jbonofre Aug 25, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bundleContext.getService() increments the framework use-count for this service reference. If loader.load(delegate) subsequently throws, addingService() propagates RuntimeCamelException. The ServiceTracker treats a throwing addingService() as "not tracked", so removedService() is never called for this reference and bundleContext.ungetService() is never invoked.

The use-count is permanently stuck above zero; the originating bundle cannot be cleanly uninstalled for the lifetime of the OSGi framework. Fix: wrap loader.load() in a try-catch and call bundleContext.ungetService(serviceReference) in the catch before re-throwing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. addingService now wraps the load in a try/catch, drops the reference from the tracked-loader map and calls bundleContext.ungetService(serviceReference) before re-throwing.

While in there: the normal path leaked too. removedService never ungot the service either, so every tracked loader's use count stayed above zero for the lifetime of the framework, not only the ones whose load() threw. Since addingService is what takes the service — we are the customizer, so the tracker does not take it for us — releasing it is ours to do on both paths, and removedService now ungets as well. Flagging that explicitly rather than folding it in silently.

Both leaks predate this PR: git diff main... shows addingService's body was byte-identical to main apart from the synchronized keyword.

Tests: addingServiceReleasesTheServiceWhenLoadingFails and removedServiceReleasesTheService, both of which fail against the previous commit.

Claude Code on behalf of Andrea Cosentino

trackedLoaders.put(serviceReference, loader);
try {
LOG.debug("loading type converter from bundle: {}", serviceReference.getBundle().getSymbolicName());
if (delegate != null) {
DefaultTypeConverter current = delegate;

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delegate is read here without the monitor, so a loader arriving during a rebuild can end up recorded in trackedLoaders but present in no registry, with nothing left to trigger another rebuild.

  1. T1 in synchronized getDelegate(): delegate == null, enters createRegistry(), executes new ArrayList<>(trackedLoaders.keySet()) — empty.
  2. T2 (framework dispatch, addingService for sr_A): trackedLoaders.put(sr_A, loader) succeeds, then this line reads delegate as null because T1 has not assigned yet, so the if (current != null) branch is skipped and load() is never called.
  3. T1 completes and publishes a registry that does not contain sr_A's converters.

sr_A is in trackedLoaders, so no further service event fires and the next rebuild only happens on an unrelated unregistration. Until then every conversion sr_A provided fails with NoTypeConversionAvailableException.

Recording the loader and deciding whether to load it are two steps raced against a build that is atomic with neither. Note the pre-PR code had the same hole via tracker.getServiceReferences() (the tracker's tracked map is only populated after addingService returns), so this is not a regression — but it is in the method being rewritten, and the trackedLoaders map is now the place where it could actually be closed.

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);
}
}
Expand All @@ -99,9 +138,21 @@ public Object addingService(ServiceReference<TypeConverterLoader> serviceReferen
public void modifiedService(ServiceReference<TypeConverterLoader> serviceReference, Object o) {
}

// not synchronized, for the same reason as addingService

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is anchored here, but the defect is this.delegate = null at line 163 just below (and the ServiceHelper.stopService(this.delegate) above it) — an unsynchronized write to a field getDelegate() now guards with a monitor, so an invalidation can be swallowed by an in-flight rebuild.

Interleaving:

  1. T1 in synchronized getDelegate(): delegate == null, enters createRegistry(), snapshots trackedLoaders (contains sr_A), begins loading.
  2. T2 (framework dispatch, unsynchronized removedService for sr_A): removes sr_A from trackedLoaders, calls ServiceHelper.stopService(this.delegate) on the still-null field, sets delegate = null.
  3. T1 finishes and assigns delegate = the registry it built with sr_A's converters.

The invalidation is lost and nothing will trigger another rebuild, so the context permanently serves converters from a bundle that is gone. The mirror-image interleaving hands a stopped delegate to a live caller.

I see this is split out to #743, which is reasonable as a scoping call — but widening getDelegate() to a full monitor in this PR makes the window larger, not smaller, so it is worth confirming the deferral still holds rather than inheriting it from the pre-monitor shape.

@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()) {

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!isStopped() also suppresses this warning in every pre-STARTED state, so the diagnostic #734 asked for is missing exactly in the lazy-init window this class documents.

From BaseService (camel-api 4.18):

public boolean isStopped() {
    return status < STARTING || status >= STOPPED;
}

with NEW = 0, BUILT = 1, INITIALIZING = 2, INITIALIZED = 3, STARTING = 4 ... STOPPED = 9 ... FAILED = 12.

So isStopped() is true for NEW, BUILT, INITIALIZING, INITIALIZED and FAILED — not just after a stop. getDelegate()'s own comment below says it "may be called during doInit() ... which happens before doStart()". An OsgiTypeConverter that is already serving conversions but has not reached STARTED will therefore discard its registry on a loader unregistration with no log line at all, which is the invisibility this PR exists to remove.

isStopping() alone already achieves the stated goal (no line-per-loader on tracker.close(), since stop() sets status = STOPPING before calling doStop()). The && !isStopped() half is what breaks it; if a post-stop guard is also wanted, status != STOPPED is the narrow form.

// 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) {
Expand All @@ -112,6 +163,16 @@ public void removedService(ServiceReference<TypeConverterLoader> serviceReferenc
this.delegate = null;

@jbonofre jbonofre Aug 26, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking, though I would rather see it split out than folded in here — see the bottom.

This write is not under the monitor that getDelegate() now takes, so making getDelegate() synchronized buys mutual exclusion between readers only, never against the writer. The invalidation can be swallowed entirely:

A: getDelegate() takes `this`, delegate == null, enters createRegistry()
   (loader L already loaded into the new registry)
B: removedService(L) -> stopService(this.delegate)  // still null, no-op
                     -> this.delegate = null        // no monitor held
A: delegate = <registry built with L in it>

I reproduced this by stalling at the end of createRegistry(): after removedService returns, getDelegate() hands back the same instance that was built with the removed loader in it. Assertion assertSame(built, afterRemoval) passes. So converters belonging to an unregistered bundle keep serving indefinitely, and no subsequent event will invalidate them.

This predates the PR — the old unsynchronized if (delegate == null) had the same lost write. But it sits squarely inside #734's point 1, and synchronized getDelegate() makes the window wider, not narrower: the monitor is now held across the full rebuild (core converter scan plus every loader.load()), so there is much more time for a removedService to land inside it.

An epoch counter would close it — read a generation before createRegistry(), re-check it before the assign, discard and retry if removedService bumped it meanwhile.

That said: this is the same underlying problem as the eager-stop race you raised on the getDelegate() thread — invalidation and stop both happening outside the monitor, to an instance callers already hold. I would rather have one follow-up issue covering both than see either bolted on here. Your call, but if you open that issue, put this in it and I will not hold the PR on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split out as you asked: #743, which covers both this lost invalidation and the eager-stop race from the getDelegate() thread, since they are the same shape — mutation of an instance callers already hold, performed outside the monitor that publishes it — and a fix for one constrains the fix for the other.

Your epoch-counter sketch is in there, along with your reproduction (stalling at the end of createRegistry(), assertSame(built, afterRemoval) passing) and the point that #739 widens the window rather than narrowing it, since the monitor is now held across the whole rebuild.

Nothing about it is fixed in this PR.

Claude Code on behalf of Andrea Cosentino

}

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();
Expand All @@ -121,6 +182,14 @@ protected void doStart() throws Exception {
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;
}
Comment thread
jbonofre marked this conversation as resolved.
Expand Down Expand Up @@ -173,27 +242,33 @@ public <T> T tryConvertTo(Class<T> type, Object value) {

@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));

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keyed map only prunes the addTypeConverter / addConverter pair. addTypeConverters, addBulkTypeConverters and addFallbackTypeConverter are keyed by contributed-instance identity and have no removal API, so the map still grows without bound and still pins bundle classloaders.

removeTypeConverter is the only pruning path and it only deletes a TypeConvertible key. TypeConverterRegistry exposes no removeTypeConverters / removeFallbackTypeConverter / removeBulkTypeConverters, so entries recorded here (and at the addBulkTypeConverters / addFallbackTypeConverter calls below) live until doStop().

That breaks the stated rationale for keying on the affected path. "Re-registering replaces rather than appends, which is what a Blueprint container refresh does" is true for addTypeConverter, but a container refresh instantiates a new bean, so addTypeConverters(newBeanInstance) hashes to a fresh identity key and appends. 50 refreshes of a bundle contributing a TypeConverters bean leave 50 entries, each strongly holding the bean and through it the classloader of a bundle that may already be uninstalled — the leak shape the review flagged, on the path the PR description names as its motivating case. addRemovePairsDoNotAccumulate only exercises addTypeConverter / removeTypeConverter.

Secondary point: all three share one untyped key space, and BulkTypeConverters extends Ordered, TypeConverter, so the same instance passed to both addBulkTypeConverters and addFallbackTypeConverter collides and one registration is dropped from the replay. (CoreTypeConverterRegistry.addBulkTypeConverters is also a bare return in 4.18, so recording that one is dead weight either way.)

}

@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));

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaying after the loader loop inverts fallback converter precedence, because addFallbackTypeConverter prepends.

CoreTypeConverterRegistry.addFallbackTypeConverter (verified in bytecode) is:

fallbackConverters.add(0, new FallbackTypeConverter(typeConverter, canPromote));

i.e. last added == index 0 == tried first. That is what createRegistry()'s "Just make sure we install the high ranking fallback converter at last" comment relies on: the highest-ranking ServiceReference sorts last, so its fallback lands at index 0.

replayProgrammaticRegistrations(answer) then runs after the entire loop, so any programmatic fallback is prepended in front of the high-ranking loader fallback that the sort deliberately put there.

In the live registry the opposite ordering held: a fallback registered at time T sat behind every loader that arrived after T (addingService -> loader.load(delegate) -> prepend). So a single loader unregistration silently flips which fallback wins for every unmatched conversion in the context. The field Javadoc's claim that the rebuilt registry is brought "back to the same state" does not hold for fallbacks.

}

@Override
Expand All @@ -203,7 +278,7 @@ public TypeConverter lookup(Class<?> toType, Class<?> fromType) {

@Override
public void setInjector(Injector injector) {
getDelegate().setInjector(injector);
register(INJECTOR_KEY, registry -> registry.setInjector(injector));
}

@Override
Expand All @@ -228,7 +303,8 @@ public LoggingLevel getTypeConverterExistsLoggingLevel() {

@Override
public void setTypeConverterExistsLoggingLevel(LoggingLevel loggingLevel) {
getDelegate().setTypeConverterExistsLoggingLevel(loggingLevel);
register(TYPE_CONVERTER_EXISTS_LOGGING_LEVEL_KEY,
registry -> registry.setTypeConverterExistsLoggingLevel(loggingLevel));
}

@Override
Expand All @@ -238,10 +314,13 @@ public TypeConverterExists getTypeConverterExists() {

@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

@jbonofre jbonofre Aug 25, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fast path reads answer = delegate (volatile) and, if non-null, skips the synchronized block and returns answer directly to the caller. However, a concurrent removedService() (holding synchronized(this)) can call ServiceHelper.stopService(this.delegate) and set this.delegate = null after Thread A's null-check but before Thread A uses the returned value. Thread A then calls conversion methods on a stopped converter, resulting in IllegalStateException or silent data loss.

DCL is only safe when the published object is truly immutable after publication. A delegate that can be stopped and replaced does not meet that bar. Consider always going through the lock in getDelegate() — type conversion is not so hot that the lock overhead is measurable compared to actual conversion work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed as asked — getDelegate() is now plainly synchronized, with no double-checked read.

One thing I would rather be straight about than let the thread read as closed: it does not actually fix the race you describe. The caller still receives the delegate and uses it after the monitor is released, so removedService can stop it a moment later just the same — always locking narrows the window, it does not close it. The race is also older than this PR; before it, getDelegate() read the field with no lock at all.

Closing it properly means not stopping the outgoing delegate eagerly: let the old instance keep serving in-flight conversions and let it go when nothing references it. That is a different change and I would rather not smuggle it into this one. Shall I open an issue for it?

Claude Code on behalf of Andrea Cosentino

// before creating the registry - this is important because getDelegate() may be
Expand Down Expand Up @@ -279,28 +358,73 @@ public Set<ClassLoader> getClassLoaders() {
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);

@jbonofre jbonofre Aug 27, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replay is outside any try/catch, so one throwing registration permanently bricks the registry — and with TypeConverterExists.Fail it is guaranteed to throw.

I disassembled CoreTypeConverterRegistry from camel-base-4.18.1.jar:

  • addConverter(TypeConvertible, TypeConverter) is a bare converters.put(...) — no duplicate check.
  • addTypeConverter(Class, Class, TypeConverter) routes through addOrReplaceTypeConverter() -> onTypeConverterExists(), which returns true for Override, false for Ignore, and otherwise throws TypeConverterExistsException.

So:

  1. Context is configured typeConverterExists=Fail.
  2. A Blueprint bean calls addTypeConverter(Foo, Bar, tc) while only core converters are loaded — no conflict, applied and recorded.
  3. Bundle X's TypeConverterLoader later registers Bar -> Foo via addConverter() (plain put, no check).
  4. Any loader unregisters; the delegate is discarded.
  5. Next getDelegate(): loaders load first, then this replay calls addTypeConverter(Foo, Bar, tc) -> existing converter found -> TypeConverterExistsException escapes createRegistry() and getDelegate().

delegate stays null, so every convertTo / tryConvertTo / lookup in the container throws forever, and each retry re-throws because the offending entry is still in programmaticRegistrations. The loader loop just above is wrapped in try/catch (Throwable); the replay is not.

Even on the default path this is lossy rather than fatal: the constructor sets typeConverterExists = Ignore and typeConverterExistsLoggingLevel = LoggingLevel.DEBUG, so the same collision silently drops the programmatic converter and logs only at DEBUG. programmaticConverterSurvivesARegistryRebuild does not catch it because the Marker interface is chosen specifically so the pair cannot collide with a core converter.


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,
Expand Down Expand Up @@ -332,7 +456,7 @@ public Map<Class<?>, TypeConverter> lookup(Class<?> toType) {

@Override
public void addConverter(TypeConvertible<?, ?> typeConvertible, TypeConverter typeConverter) {
getDelegate().addConverter(typeConvertible, typeConverter);
register(typeConvertible, registry -> registry.addConverter(typeConvertible, typeConverter));
}

}
Loading
Loading