jbonofre commented on code in PR #739:
URL: https://github.com/apache/camel-karaf/pull/739#discussion_r3872608021
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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);
Review Comment:
**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.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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());
Review Comment:
**`sr.getBundle().getSymbolicName()` is dereferenced eagerly inside the
`try`, so a concurrently-stopping bundle turns a debug log into an aborted
registry rebuild.**
`ServiceReference.getBundle()` returns `null` once the service is
unregistered. This loop already anticipates that window — the `loader == null`
guard four lines up is commented "unregistered between the snapshot and here" —
and then dereferences `getBundle()` unconditionally for the same window. The
argument is evaluated regardless of log level, so DEBUG being off does not help.
Because the call sits inside the `try { ... } catch (Throwable t)` that
rethrows as `RuntimeCamelException`, the NPE does not just skip one loader: it
escapes `createRegistry()` and `getDelegate()`, so a routine bundle stop makes
the whole type converter registry unbuildable.
Worth noting the inconsistency: `removedService` guards this exact call
(`serviceReference.getBundle() != null ? ... : serviceReference`), but this
line and the one in `addingService` do not. The tests mask it because `setUp()`
stubs `serviceReference.getBundle()` to a non-null mock.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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
@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()) {
Review Comment:
**`!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):
```java
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.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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() {
Review Comment:
**Making `getDelegate()` fully synchronized turns the conversion hot path
into a global serialization point, and blocks every conversion in the container
for the full duration of a rebuild.**
`convertTo`, `tryConvertTo`, `mandatoryConvertTo`, `lookup`, `allowNull`,
`size`, `getStatistics` and `getInjector` all funnel through here. Previously
`delegate` was `volatile` and read without a lock, so the hot path was
contention-free; now every conversion on every route thread contends one
monitor (and biased locking is gone as of JDK 15, so it inflates).
The rebuild case is the sharper one: when a single `TypeConverterLoader`
unregisters, the next `getDelegate()` holds this monitor across `init()` +
`loadCoreAndFastTypeConverters()` (bundle resource scanning) + `loader.load()`
for every tracked loader + the replay. Every conversion in the container blocks
for that whole span. `registrationCannotInterleaveWithARebuild` demonstrates
precisely this with its 2 s stall.
The comment's "conversion work dwarfing an uncontended monitor" only holds
while uncontended. Keeping a volatile fast-path read and synchronizing only the
build gives the same build-once guarantee at no hot-path cost:
```java
DefaultTypeConverter d = delegate;
if (d != null) {
return d;
}
synchronized (this) { ... }
```
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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));
Review Comment:
**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.)
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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
Review Comment:
**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.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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));
}
@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));
Review Comment:
**Replaying after the loader loop inverts fallback converter precedence,
because `addFallbackTypeConverter` prepends.**
`CoreTypeConverterRegistry.addFallbackTypeConverter` (verified in bytecode)
is:
```java
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.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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() {
Review Comment:
**`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.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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() {
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;
Review Comment:
**`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.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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.
Review Comment:
**This invariant does not hold. Anchoring here because the comment states
it; the calls that break it are `ensureTrackerOpen()` inside synchronized
`getDelegate()` (line 329) and `loader.load(answer)` / `answer.init()` in this
method.**
The comment here, plus the ones on `addingService` and `removedService`,
justify unsynchronizing the callbacks and abandoning
`tracker.getServiceReferences()` on the grounds that no framework call may
happen under this monitor. But inside synchronized `getDelegate()`:
- `ensureTrackerOpen()` -> `ServiceTracker.open()` acquires framework
service-registry locks **and synchronously dispatches `addingService` for every
initial service**;
- `answer.init()` / `answer.loadCoreAndFastTypeConverters()` do
bundle-wiring resource scanning;
- `loader.load(answer)` twelve lines below runs arbitrary third-party bundle
code that may call back into the framework or into this same
`OsgiTypeConverter`.
So the `this` -> framework-lock edge the redesign was meant to remove is
still present, just relocated. A concrete shape: the framework dispatch thread
runs `addingService` -> `loader.load(current)` where a `CamelContextAware`
loader performs a conversion -> `getDelegate()` -> blocks on `this`, while
another thread holds `this` inside `tracker.open()`.
The residual risk may well be acceptable, but the comments assert the
mitigation is complete when it is partial, which is the part that will mislead
the next reader.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -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);
+
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);
+ }
+ }
+
+ /**
Review Comment:
**Two consecutive Javadoc blocks: this one is orphaned by the `/** */` for
`programmaticRegistrationCount()` that follows, so `register()` ends up with no
Javadoc and the surviving comment documents the wrong method.**
Java attaches only the last block before a declaration. This block — the
load-bearing explanation of why apply-and-record has to be atomic, which both
the commit message and the PR description lean on — becomes a dangling comment
attached to nothing, and `register()` at line 421 is undocumented. The `{@link
#getDelegate()}` reference in it is invisible to doclint too.
Looks like a rebase/squash artifact; moving this block down to immediately
precede `register()` fixes it.
_AI-generated review on behalf of JB Onofré_
##########
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;
Review Comment:
**The new `java.util` imports were inserted into the middle of the existing
`org.apache.camel` group, splitting it in two.**
Lines 19-22 are `org.apache.camel.*`, lines 23-31 are `java.util.*`, then
line 33 returns to `org.apache.camel.impl.converter.DefaultTypeConverter` and
`org.apache.camel.spi.*`.
`OsgiTypeConverter.java` itself (lines 19-28) and the rest of the module put
`java.*` first, then a blank line, then the third-party groups. There is no
enforced formatter plugin here so nothing fails the build, but this guarantees
a churn diff the next time anyone runs an IDE import organiser over the file.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/main/java/org/apache/camel/karaf/core/OsgiTypeConverter.java:
##########
@@ -203,7 +278,7 @@ public TypeConverter lookup(Class<?> toType, Class<?>
fromType) {
@Override
public void setInjector(Injector injector) {
Review Comment:
**`setInjector` only records a replay lambda and never updates the
`injector` field, so every rebuilt registry is constructed with the
constructor-time injector and loads its core converters before the replay
corrects it.**
`createRegistry()` passes the `final` field `this.injector` to `new
OsgiDefaultTypeConverter(...)`, then calls `answer.init()` and
`answer.loadCoreAndFastTypeConverters()`, and only afterwards calls
`replayProgrammaticRegistrations(answer)`. So after a rebuild, converters
instantiated during core loading used the **old** injector; the configured one
is swapped in only after the fact, and anything already created keeps the stale
reference.
`TYPE_CONVERTER_EXISTS_KEY` and `TYPE_CONVERTER_EXISTS_LOGGING_LEVEL_KEY`
have the same ordering problem, and it compounds the replay issue on
`replayProgrammaticRegistrations`: a context configured `Override` or `Fail`
has all its core and loader converters registered under the constructor
defaults (`Ignore` / `DEBUG`), and the real policy only takes effect partway
through the replay, in insertion order.
This is strictly better than pre-PR behaviour (where the setting was lost
entirely), so not a blocker — but "brought back to the same state" is not what
happens.
_AI-generated review on behalf of JB Onofré_
##########
core/camel-core-osgi/src/test/java/org/apache/camel/karaf/core/OsgiTypeConverterTest.java:
##########
@@ -110,4 +125,243 @@ void removedServiceShouldInvalidateDelegate() throws
Exception {
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),
Review Comment:
**Proving this by waiting 2 s for a `TimeoutException` is a `Thread.sleep`
in disguise, and it passes vacuously if the second pool thread has not yet
entered `addTypeConverter`.**
Two problems:
1. It adds a hard 2 s to every build and asserts a negative by elapsed time
— the pattern the project's Awaitility guidance exists to eliminate ("flaky,
slow, and non-deterministic").
2. It is not actually a guard. On a loaded CI box or with a cold thread
pool, the `registrar` thread may not have reached the synchronized `register()`
at all; the `get()` times out anyway and the test reports success even against
an implementation where `register()` is **not** synchronized. The PR
description already notes this test passes against its predecessor.
A deterministic positive assertion is available and the test almost makes it
already: release the build, join both futures, then assert the registration is
present in the published registry — which lines 358-359 do. Dropping the timing
assertion loses nothing and removes the 2 s.
_AI-generated review 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]