Repository navigation
fix(#734): build the type converter registry once, and say when it is discarded #739
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
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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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 OsgiTypeConverter(BundleContext bundleContext, CamelContext camelContext, | |
| this.tracker = new ServiceTracker<>(bundleContext, TypeConverterLoader.class.getName(), this); | ||
| } | ||
|
|
||
| private void ensureTrackerOpen() { | ||
| private synchronized void ensureTrackerOpen() { | ||
|
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.
Nothing can clean that up — The same hole exists with no race at all: |
||
| if (!trackerOpened) { | ||
| tracker.open(); | ||
| trackerOpened = true; | ||
| } | ||
| } | ||
|
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) { | ||
|
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.
The use-count is permanently stuck above zero; the originating bundle cannot be cleanly uninstalled for the lifetime of the OSGi framework. Fix: wrap
Contributor
Author
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. Fixed. While in there: the normal path leaked too. Both leaks predate this PR: Tests: 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; | ||
|
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.
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 |
||
| 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 Object addingService(ServiceReference<TypeConverterLoader> serviceReferen | |
| public void modifiedService(ServiceReference<TypeConverterLoader> serviceReference, Object o) { | ||
| } | ||
|
|
||
| // not synchronized, for the same reason as addingService | ||
|
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 comment is anchored here, but the defect is Interleaving:
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 |
||
| @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()) { | ||
|
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.
From public boolean isStopped() {
return status < STARTING || status >= STOPPED;
}with So
|
||
| // 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 void removedService(ServiceReference<TypeConverterLoader> serviceReferenc | |
| this.delegate = 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. Blocking, though I would rather see it split out than folded in here — see the bottom. This write is not under the monitor that I reproduced this by stalling at the end of This predates the PR — the old unsynchronized An epoch counter would close it — read a generation before That said: this is the same underlying problem as the eager-stop race you raised on the
Contributor
Author
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. Split out as you asked: #743, which covers both this lost invalidation and the eager-stop race from the Your epoch-counter sketch is in there, along with your reproduction (stalling at the end of 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(); | ||
|
|
@@ -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; | ||
| } | ||
|
jbonofre marked this conversation as resolved.
|
||
|
|
@@ -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)); | ||
|
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. The keyed map only prunes the
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 Secondary point: all three share one untyped key space, and |
||
| } | ||
|
|
||
| @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)); | ||
|
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. Replaying after the loader loop inverts fallback converter precedence, because
fallbackConverters.add(0, new FallbackTypeConverter(typeConverter, canPromote));i.e. last added == index 0 == tried first. That is what
In the live registry the opposite ordering held: a fallback registered at time T sat behind every loader that arrived after T ( |
||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
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. The fast path reads 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
Contributor
Author
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. Changed as asked — 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 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 | ||
|
|
@@ -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); | ||
|
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. The replay is outside any try/catch, so one throwing registration permanently bricks the registry — and with I disassembled
So:
Even on the default path this is lossy rather than fatal: the constructor sets |
||
|
|
||
| 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 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)); | ||
| } | ||
|
|
||
| } | ||
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.
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.
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.
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 bothdoStart()andgetDelegate(), andtrackerOpenedis a check-then-act, so two threads arriving together could both calltracker.open(). It is cheap insurance rather than something the activator guarantees.What changed is that it is now safe to hold:
addingService/removedServiceno longer take this monitor, sotracker.open()firingtrackInitial()synchronously from inside the lock cannot come back round and block on it.Claude Code on behalf of Andrea Cosentino