fix(#733): do not let an uninstalled bundle poison factory resolution - #741
Conversation
| URL url; | ||
| for (Bundle bundle : bundles) { | ||
| url = bundle.getEntry(getResourcePath() + name); | ||
| URL url = bundle.getEntry(getResourcePath() + name); |
There was a problem hiding this comment.
bundleContext.getBundles() returns a snapshot, and per the OSGi spec Bundle.getEntry() throws IllegalStateException if this bundle has been uninstalled. Before this change, the loop broke at the winner, so only bundles ahead of it were called, and since the descriptors live in early-installed bundles, that was typically a handful.
Now every one of the ~300 installed bundles is called on every key resolution, so the exposure widens by about two orders of magnitude.
What turns that from a transient error into a lasting one is the caching in Camel. DefaultFactoryFinder.addToClassMap:
} catch (Exception e) {
classesNotFoundExceptions.put(key, e);
throw RuntimeCamelException.wrapRuntimeException(e);
}and on entry it rethrows the cached exception for that key.
IllegalStateException is an Exception, and getResource is called from inside this lambda (see findClass, line 50). So a feature:uninstall or bundle:update racing a resolution, including the rolling upgrade this PR is about (leaves that key throwing for the life of the context, until clear()).
I think we should skip bundles in state Bundle.UNINSTALLED and wrap the getEntry call in a try/catch (IllegalStateException) and continue (or keep the break resolution and do the duplicate detection in a separate pass that cannot fail the lookup).
There was a problem hiding this comment.
Addressed, and this is now the point of the PR. The scan skips Bundle.UNINSTALLED and catches IllegalStateException from getEntry (needed as well as the state check, for the bundle uninstalled between the two).
Confirmed against the 4.18.1 bytecode: the mapping function in addToClassMap catches Exception, does classesNotFoundExceptions.put(key, e) and rethrows, and on entry addToClassMap rethrows the cached exception for that key. So yes — one race and the key is dead until clear().
Two tests cover it: an UNINSTALLED bundle is never asked for an entry, and a bundle that throws mid-scan does not fail the lookup for the bundles after it.
| Bundle[] bundles = bundleContext.getBundles(); | ||
|
|
||
| URL url; | ||
| for (Bundle bundle : bundles) { |
There was a problem hiding this comment.
Dropping the break makes scan on all bundles. For performance reason, I would keep it.
There was a problem hiding this comment.
Agreed, the break is back. Selection and scan cost are unchanged from main.
Worth recording why it matters more than it looks: misses cache too (classesNotFound), so without the break every key — hit or miss — costs a full sweep of all installed bundles, ~300 in a typical Camel Karaf install.
| // the operator chose. Say so rather than resolving silently: during a rolling upgrade with two | ||
| // versions of a bundle installed side by side, this is how a patched bundle gets ignored. The | ||
| // result is also cached per key by findClass, so the choice made here is sticky. | ||
| LOG.warn("Factory descriptor {} is provided by more than one bundle. Using the one from {}," |
There was a problem hiding this comment.
The default camel-core feature installs both camel-xml-io and camel-xml-jaxb, and both bundles ship META-INF/services/org/apache/camel/modelxml-dumper with different implementation classes.
It's not a big deal in OSGi as we control the loading, so not sure this warn is useful. We could also fix the duplication in the camel-core feature (we don't need the two bundles).
There was a problem hiding this comment.
Confirmed, and it is a guaranteed false positive rather than a possible one — the two bundles ship different implementations:
camel-xml-io→class=org.apache.camel.xml.LwModelToXMLDumpercamel-xml-jaxb→class=org.apache.camel.xml.jaxb.JaxbModelToXMLDumper
and both are in the default camel-core feature (camel-features.xml:285 and :288). So a stock install would warn at every startup.
Dropped the WARN. Note the same ambiguity exists in flat-classpath Camel, where classpath order picks the winner — it is upstream, not a Karaf artifact.
Trimming one of the two bundles from the camel-core feature is a separate discussion; since they provide different dumpers it is a behaviour change, not just a dedup.
| } | ||
| } | ||
|
|
||
| if (alsoProviding != null) { |
There was a problem hiding this comment.
#733 is about a rolling upgrade: a patched bundle is installed on top on the old one and silently ignored.
That sequence never reaches this branch here:
camel-foo/x.y.zinstalled, context starts,findClass("some-factory")scans, find one provider, caches the class in theclassMap: no ambiguity, no WARN.- Users installs
camel-foo/x.y.f - The running context keep using x.y.z class.
addToClassMapshort-circuits onclassMap, sogetResourceis never called again for that key. And the finder that is actually ignoring the patched bundle never warns.
The WARN only appears in contexts create after both bundles are installed. The same blind spot applies during Karaf startup, where features install progressively and an early-starting context can scan before all providers exist.
So the stated goal ("it is what tells us whether the ambiguity happens in practice") is not met for the #733 scenario. Catching it needs a BundleListener or cache invalidation on bundle events, in the spirit of how OsgiTypeConverter in validates its delegate on service changes.
There was a problem hiding this comment.
I have not tried to patch around it: no logging inside getResource can see that sequence, because step 3 never reaches getResource at all. Confirmed in the bytecode: classMap.computeIfAbsent, so a resolved key never re-scans.
So the PR no longer claims to detect it. What it keeps is the cheap, honest part: a DEBUG line naming the bundle that supplied each descriptor, which answers "which one is actually in use" for a context that resolves after the fact.
I have left #733 open for the sticky-cache half. Doing it properly means invalidating on bundle events the way OsgiTypeConverter revalidates its delegate: with the wrinkle that OsgiFactoryFinder has no dispose hook today (it is created per resource path by OsgiFactoryFinderResolver and never torn down), so a BundleListener registered from it would leak. That wants its own PR.
1af9379 to
1013d82
Compare
|
@oscerd I pushed to this branch directly rather than waiting on a round trip: sorry for stepping on your PR, revert me if you disagree with any of it. Kept you as the commit author. It is rebased on current Summary of what changed relative to your revision:
I am leaving #733 open: the sticky-cache half is untouched here and needs invalidation on bundle events, along the lines of what |
|
Looks good to me |
…lution getResource walks bundleContext.getBundles() and returns the first bundle with a matching descriptor. getBundles() returns a snapshot, and per the OSGi spec Bundle.getEntry() throws IllegalStateException once a bundle has been uninstalled, so a concurrent feature:uninstall or bundle:update can make the scan throw on a bundle that has nothing to do with the factory being resolved. findClass calls getResource from inside DefaultFactoryFinder.addToClassMap, which catches Exception, stores it in classesNotFoundExceptions and rethrows it on every later lookup of the same key. That turns the transient race into a lasting failure: the key stays broken for the life of the context, until clear(). Skip bundles already in state UNINSTALLED, and treat an IllegalStateException from getEntry as "this bundle has no such entry" rather than letting it fail the whole scan. Selection is unchanged. The scan still stops at the first match, so install order still decides and there is no added cost on a path that is walked once per key. Log the bundle that supplied the descriptor at DEBUG: with several providers installed, that is what lets an operator tell which one is actually in use. Deliberately not warning when several bundles provide the same descriptor. The default camel-core feature installs both camel-xml-io and camel-xml-jaxb, and both ship META-INF/services/org/apache/camel/modelxml-dumper with a different implementation class, so a stock install would warn on every startup. That ambiguity is inherited from flat-classpath Camel, where classpath order picks the winner in the same way. Not addressed here: the rolling-upgrade half of apache#733. Once a key resolves, addToClassMap short-circuits on classMap and getResource is never called again for it, so a patched bundle installed afterwards is still silently ignored by an already-running context. Catching that needs cache invalidation on bundle events, in the spirit of OsgiTypeConverter revalidating its delegate on service changes, and is a separate change with its own blast radius. BundleEntry becomes package private. It was private while getResource, which returns it, is public, so the modifier was not restricting anything; this makes it reachable from the tests in the package.
1013d82 to
e784cd9
Compare
… (backport of #741) (#752) getResource walks bundleContext.getBundles() and returns the first bundle with a matching descriptor. getBundles() returns a snapshot, and per the OSGi spec Bundle.getEntry() throws IllegalStateException once a bundle has been uninstalled, so a concurrent feature:uninstall or bundle:update can make the scan throw on a bundle that has nothing to do with the factory being resolved. findClass calls getResource from inside DefaultFactoryFinder.addToClassMap, which catches Exception, stores it in classesNotFoundExceptions and rethrows it on every later lookup of the same key. That turns the transient race into a lasting failure: the key stays broken for the life of the context, until clear(). Skip bundles already in state UNINSTALLED, and treat an IllegalStateException from getEntry as "this bundle has no such entry" rather than letting it fail the whole scan. Selection is unchanged. The scan still stops at the first match, so install order still decides and there is no added cost on a path that is walked once per key. Log the bundle that supplied the descriptor at DEBUG: with several providers installed, that is what lets an operator tell which one is actually in use. Deliberately not warning when several bundles provide the same descriptor. The default camel-core feature installs both camel-xml-io and camel-xml-jaxb, and both ship META-INF/services/org/apache/camel/modelxml-dumper with a different implementation class, so a stock install would warn on every startup. That ambiguity is inherited from flat-classpath Camel, where classpath order picks the winner in the same way. Not addressed here: the rolling-upgrade half of #733. Once a key resolves, addToClassMap short-circuits on classMap and getResource is never called again for it, so a patched bundle installed afterwards is still silently ignored by an already-running context. Catching that needs cache invalidation on bundle events, in the spirit of OsgiTypeConverter revalidating its delegate on service changes, and is a separate change with its own blast radius. BundleEntry becomes package private. It was private while getResource, which returns it, is public, so the modifier was not restricting anything; this makes it reachable from the tests in the package. Co-authored-by: Andrea Cosentino <ancosen@gmail.com>
Fixes #733
What
OsgiFactoryFinder.getResourcewalksbundleContext.getBundles()and returnsthe first bundle with a matching factory descriptor.
getBundles()returns a snapshot, and per the OSGi specBundle.getEntry()throws
IllegalStateExceptiononce a bundle has been uninstalled. So afeature:uninstallorbundle:updaterunning concurrently with a resolutioncan make the scan throw on a bundle that has nothing to do with the factory
being looked up.
What turns that transient race into a lasting failure is the caching in Camel.
findClasscallsgetResourcefrom insideDefaultFactoryFinder.addToClassMap, whose mapping function does:and on entry
addToClassMaprethrows the cached exception for that key.IllegalStateExceptionis anException, so the key stays broken for the lifeof the context, until
clear().How
Bundle.UNINSTALLED.IllegalStateExceptionfromgetEntryas "this bundle has no suchentry" and continue, rather than letting it fail the whole scan. This covers
the bundle that is uninstalled between the state check and the call.
installed, that is what lets an operator tell which one is actually in use —
the "observable" half of what OsgiFactoryFinder: first-match-wins bundle scan is order-dependent and silently sticky #733 asks for.
Selection is unchanged. The scan still
breaks at the first match, soinstall order still decides and there is no added cost on a path that is walked
once per key.
Why no WARN on duplicate providers
An earlier revision of this PR dropped the
breakand warned when more than onebundle provided the same descriptor. That is not worth it:
camel-corefeature installs bothcamel-xml-ioandcamel-xml-jaxb, and both shipMETA-INF/services/org/apache/camel/modelxml-dumper, with a differentimplementation class (
LwModelToXMLDumpervsJaxbModelToXMLDumper). A stockinstall would warn on every startup. The ambiguity is inherited from
flat-classpath Camel, where classpath order picks the winner the same way.
typical Camel Karaf), instead of stopping at the winner.
IllegalStateExceptionexposure above from "bundles ahead ofthe winner" to "every installed bundle".
What this does not fix
The rolling-upgrade half of #733. Once a key resolves,
addToClassMapshort-circuits on
classMapandgetResourceis never called again for it, soa patched bundle installed afterwards is still silently ignored by an
already-running context — and no amount of logging inside
getResourcecan seethat, because it is not reached. Catching it needs cache invalidation on bundle
events, in the spirit of how
OsgiTypeConverterrevalidates its delegate onservice changes. That is a separate change with its own blast radius; #733 stays
open for it.
Tests
OsgiFactoryFinderTest: no provider, single provider, first-match-wins withseveral providers (pinning the selection the rest of the resolution path depends
on), an
UNINSTALLEDbundle never being asked for an entry, and a bundleuninstalled mid-scan not failing the lookup.
Note
BundleEntrygoes fromprivate static classto package-private.getResourceis
publicand returns it, soprivatewas never actually restrictinganything; this makes the type nameable from tests in the same package.
Claude Code on behalf of JB Onofré