jbonofre commented on code in PR #739:
URL: https://github.com/apache/camel-karaf/pull/739#discussion_r3860502088
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -57,6 +61,19 @@ 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}, in the order they were
+ * made, 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.
+ */
+ private final List<Consumer<TypeConverterRegistry>>
programmaticRegistrations = new CopyOnWriteArrayList<>();
Review Comment:
**Blocking.** This list is unbounded and never pruned, which in OSGi means
classloader retention.
Every registration ever made through the facade is retained for the life of
the `CamelContext`, and each retained lambda strongly references the
`TypeConverter` instance it captured — and therefore the contributing bundle's
classloader. After that bundle is uninstalled, nothing here lets go. That is
the leak shape this repo cares most about, and it is new in this PR: before,
discarding the delegate did at least drop the references.
Reproduced: 500 `addTypeConverter`/`removeTypeConverter` pairs leave the
list at **1000** entries, with the converter from pair 1 still strongly
reachable.
Two knock-ons worth naming:
- **Rebuild cost grows monotonically.** Every rebuild replays the whole
list, and `addTypeConverters(Object)` re-runs reflective `@Converter` scanning
each time. Since #739 also holds the instance monitor across the rebuild, the
container's conversion stall grows with the number of registrations the context
has ever seen.
- **The list has no relationship to bundle lifecycle.** A Blueprint
container refresh re-registers, appending duplicates rather than replacing.
A keyed collection — `Map<TypeConvertible<?,?>,
Consumer<TypeConverterRegistry>>`, insertion-ordered — would fix growth,
retention, and the `removeTypeConverter` ordering question in one move: a
removal deletes the entry instead of appending an inverse. It does not solve
`addTypeConverters(Object)`, which has no natural key, but that is a much
smaller surface to think about.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -112,6 +150,16 @@ public void
removedService(ServiceReference<TypeConverterLoader> serviceReferenc
this.delegate = null;
Review Comment:
**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.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -279,28 +335,59 @@ 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);
+
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) {
+ registration.accept(registry);
+ }
+ }
+
+ /**
+ * Applies a registration to the current delegate and remembers it, so
that discarding the delegate does not
+ * discard the registration with it.
+ */
+ private void register(Consumer<TypeConverterRegistry> registration) {
+ // apply first: a registration the delegate rejects is not one worth
replaying. Note getDelegate() may
+ // build the registry here, which replays the list as it stands - this
registration is added after, so
+ // it cannot be applied twice
+ registration.accept(getDelegate());
+ programmaticRegistrations.add(registration);
+ }
Review Comment:
**Blocking.** This reintroduces the bug the PR fixes, one level down.
`register()` is apply-then-record, and no monitor spans both halves. A
rebuild landing between them loses the registration from the live registry even
though it is sitting in the replay list:
```
A: addTypeConverter -> getDelegate() returns D1 -> stalls inside the apply
B: removedService -> delegate = null
C: getDelegate() -> builds D2, replays the list (r is not in it yet)
A: programmaticRegistrations.add(r)
=> r is in the replay list, but was only ever applied to the discarded D1
```
I reproduced this against this head with a registry whose `addTypeConverter`
I could stall inside: after the sequence above, `d2.lookup(String.class,
Marker.class)` returns `null`, and no further rebuild happens to correct it. So
the converter is silently absent from the live registry — exactly the symptom
`programmaticConverterSurvivesARegistryRebuild` is meant to rule out.
The comment here is right that the registration cannot be applied *twice*;
the gap is that it can be applied *zero* times.
The window is narrow for `addTypeConverter`, but `addTypeConverters(Object)`
does reflective `@Converter` scanning, and Blueprint bean registration runs
concurrently with bundle lifecycle events, so it is not theoretical.
Fix is cheap, since `getDelegate()` is reentrant on the same monitor:
```java
private synchronized void register(Consumer<TypeConverterRegistry>
registration) {
```
`removeTypeConverter` has the same split and needs the same treatment.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -121,6 +169,8 @@ 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();
ServiceHelper.stopService(this.delegate);
this.delegate = null;
}
Review Comment:
**Blocking**, and a one-liner.
`trackedLoaders` is cleared here but `programmaticRegistrations` is not, so
a stop/start cycle replays registrations from the previous lifecycle —
including converters owned by bundles that are gone by the time the context
comes back up.
Reproduced against this head: `start()` -> `addTypeConverter(String, Marker,
tc)` -> `stop()` -> `start()` -> `getDelegate().lookup(String.class,
Marker.class)` returns the converter from the previous lifecycle.
`ServiceSupport` permits stop/start, and `OsgiTypeConverter` is a
context-scoped service, so this is reachable on a context restart. Clearing the
list alongside `trackedLoaders` is enough, and it would be worth a test next to
`removedServiceReleasesTheService`.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -238,10 +291,13 @@ public TypeConverterExists getTypeConverterExists() {
@Override
public void setTypeConverterExists(TypeConverterExists
typeConverterExists) {
- getDelegate().setTypeConverterExists(typeConverterExists);
+ register(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() {
Review Comment:
Not blocking, but the invariant the safety argument rests on does not hold,
so I would like the comment and the PR description to stop asserting it.
The PR body says there is "no call into the tracker or the framework left
underneath this instance's monitor at all". There are several. `getDelegate()`
is now `synchronized` and calls `ensureTrackerOpen()`, and `tracker.open()`
does `bundleContext.addServiceListener(...)` and
`bundleContext.getServiceReferences(...)`, then synchronously runs
`trackInitial()` -> your own `addingService` -> `bundleContext.getService(...)`
— all under `this`. Separately, `createRegistry()` runs foreign `loader.load()`
under `this`.
No ABBA results, because the callbacks no longer take this monitor, and (per
your disassembly, which I verified) `Tracked` is not held across the customizer
either. So the code is *not* deadlock-prone. But "we removed the `tracker.*`
calls from `createRegistry()`" and "nothing framework-facing runs under our
monitor" are different claims, and only the first one is true. Holding `this`
across arbitrary bundle `load()` is the same shape I objected to originally; it
is now reached by a different route.
Second, on this comment specifically: "conversion work dwarfing an
uncontended monitor" is true in steady state and misleading during a rebuild,
which is the case that matters. While `createRegistry()` runs — core converter
package scan plus every tracked loader's `load()` — every conversion in the
container is blocked on this monitor, and per the `programmaticRegistrations`
thread that duration grows over the life of the context.
I am fine with the tradeoff. Please just say what it is: all conversions
serialize behind a rebuild, and the monitor is held across foreign bundle code.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -173,27 +223,30 @@ 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(registry -> registry.addTypeConverter(toType, fromType,
typeConverter));
}
@Override
public void addTypeConverters(Object typeConverters) {
- getDelegate().addTypeConverters(typeConverters);
+ register(registry -> registry.addTypeConverters(typeConverters));
}
@Override
public void addBulkTypeConverters(BulkTypeConverters bulkTypeConverters) {
- getDelegate().addBulkTypeConverters(bulkTypeConverters);
+ register(registry ->
registry.addBulkTypeConverters(bulkTypeConverters));
}
@Override
public boolean removeTypeConverter(Class<?> toType, Class<?> fromType) {
- return getDelegate().removeTypeConverter(toType, fromType);
+ boolean removed = getDelegate().removeTypeConverter(toType, fromType);
+ // replayed as well, so a rebuild reproduces the sequence rather than
resurrecting the converter
+ programmaticRegistrations.add(registry ->
registry.removeTypeConverter(toType, fromType));
+ return removed;
}
Review Comment:
Two things here, both feeding the retention problem in the
`programmaticRegistrations` thread.
**Appends an inverse instead of pruning.** Recording a *removal* rather than
deleting the matching *add* means the removed converter stays strongly
reachable forever — the add lambda still holds it. Replay reproduces the right
end state, so the tests pass, but the object is never released. Deleting the
entry would give the same end state and actually let go.
**Appends even when nothing was removed.** `removed` is computed and
returned but not consulted. A `removeTypeConverter` for a pair that was never
registered still grows the list and still costs a no-op call on every future
rebuild.
Also, as on the `register()` thread: the `getDelegate()` call and the `add`
are not atomic with respect to each other, so this has the same
lost-registration window.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/test/java/org/apache/camel/karaf/core/OsgiTypeConverterTest.java:
##########
@@ -17,7 +17,19 @@
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.atomic.AtomicInteger;
Review Comment:
Style: the `java.util.*` imports landed after the `org.apache.camel.*`
block, splitting it in two. Everywhere else in this file and in
`OsgiTypeConverter.java` the order is `java.*`, blank line, `org.*`. Please
move these above `org.apache.camel.CamelContext`.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -203,7 +256,7 @@ public TypeConverter lookup(Class<?> toType, Class<?>
fromType) {
@Override
public void setInjector(Injector injector) {
- getDelegate().setInjector(injector);
+ register(registry -> registry.setInjector(injector));
}
Review Comment:
Minor: `setInjector` / `setTypeConverterExists` /
`setTypeConverterExistsLoggingLevel` are *state*, not events, and replaying
them as ordered lambdas gets the ordering subtly wrong.
`createRegistry()` constructs `OsgiDefaultTypeConverter` with the
constructor-time `injector` field, runs `init()` and
`loadCoreAndFastTypeConverters()`, and only then replays. So a rebuilt registry
loads its core converters under the *old* injector and swaps afterwards — not
equivalent to a registry built fresh with the current one. `this.injector` is
never updated by this setter either.
Still an improvement on losing them entirely, so not blocking. But holding
them as plain fields and applying them at construction would be both simpler
and correct, and would keep three entries out of the replay list.
_Claude Code on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -67,27 +84,36 @@ public OsgiTypeConverter(BundleContext bundleContext,
CamelContext camelContext,
this.tracker = new ServiceTracker<>(bundleContext,
TypeConverterLoader.class.getName(), this);
}
- private void ensureTrackerOpen() {
+ private synchronized void ensureTrackerOpen() {
if (!trackerOpened) {
tracker.open();
trackerOpened = true;
}
}
Review Comment:
Agreed on keeping this, and your narrower justification is the right one.
One nit while you are in here, pre-existing but now more reachable: if
`tracker.open()` throws, `trackerOpened` stays `false`, but
`ServiceTracker.open()` assigns `tracked` *before* calling `trackInitial()`, so
the retry returns early and never re-runs `trackInitial()`. The initial loaders
that had not been processed yet are then never tracked at all.
`addingService` can throw `RuntimeCamelException` out of `trackInitial()`
when a loader's `load()` fails, so one bad loader at startup can silently
strand the others. Worth a separate issue rather than growing this PR —
flagging it because the method is in the diff.
_Claude Code on behalf of JB Onofré_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]