Merge pull request #1296 from arjantijms/osgi_diagnosis
Improved OSGi failure diagnosis
diff --git a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/BundleResolutionAnalyzer.java b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/BundleResolutionAnalyzer.java
new file mode 100644
index 0000000..f561c12
--- /dev/null
+++ b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/BundleResolutionAnalyzer.java
@@ -0,0 +1,922 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+
+package org.jvnet.hk2.osgiadapter;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleException;
+import org.osgi.framework.wiring.BundleCapability;
+import org.osgi.framework.wiring.BundleRequirement;
+import org.osgi.framework.wiring.BundleRevision;
+
+import static org.osgi.framework.namespace.PackageNamespace.RESOLUTION_DYNAMIC;
+import static org.osgi.resource.Namespace.EFFECTIVE_RESOLVE;
+import static org.osgi.resource.Namespace.REQUIREMENT_EFFECTIVE_DIRECTIVE;
+import static org.osgi.resource.Namespace.REQUIREMENT_FILTER_DIRECTIVE;
+import static org.osgi.resource.Namespace.REQUIREMENT_RESOLUTION_DIRECTIVE;
+import static org.osgi.resource.Namespace.RESOLUTION_MANDATORY;
+import static org.osgi.resource.Namespace.RESOLUTION_OPTIONAL;
+
+/**
+ * Explains why a bundle cannot be resolved by asking the OSGi resolver model instead of reading the prose of a {@link BundleException}.
+ * <p>
+ * One missing package cascades: in a GlassFish installation a single absent export leaves a hundred bundles unresolved, and every one of
+ * them is unresolved for the same reason. This class therefore reports the shortest path from the bundle that failed to the requirement
+ * that nothing can satisfy, and stops there. It does not describe the cascade, because the cascade is not the answer.
+ * <p>
+ * A requirement is only a root cause when it is {@link Reason#NOT_DECLARED} - nothing in the framework offers the capability - or
+ * {@link Reason#DECLARATIONS_REJECTED} - the capability is offered but no declaration satisfies the filter, which is nearly always a
+ * version range. A requirement whose provider merely happens to be unresolved as well is a step along the path, never the destination.
+ * <p>
+ * Two things this cannot see:
+ * <ul>
+ * <li>{@link BundleRequirement#matches(BundleCapability)} compares attributes against the filter and ignores {@code uses} constraints. A
+ * bundle whose every requirement is satisfiable and which still does not resolve is reported as
+ * {@link Reason#NO_UNSATISFIED_REQUIREMENT}, which in practice means a class space conflict.</li>
+ * <li>Declared capabilities and requirements are used rather than the wiring, because an unresolved bundle has no wiring. Fragments
+ * attached at resolve time therefore contribute nothing here.</li>
+ * </ul>
+ */
+public final class BundleResolutionAnalyzer {
+
+ private static final int MAX_ROOT_CAUSES = 5;
+ private static final int MAX_CANDIDATES_SHOWN = 5;
+ private static final int MAX_BUNDLES_PER_CAUSE = 5;
+ private static final int MAX_CAUSES_SHOWN = 10;
+ private static final int MAX_NEAREST_SHOWN = 3;
+
+ private static final char SEGMENT_SEPARATOR = '.';
+ private static final char PATH_SEPARATOR = '/';
+
+ /** How many jars are worth opening for one missing package, and how many hits are worth printing. */
+ private static final int MAX_SUSPECTS_PROBED = 8;
+ private static final int MAX_SUSPECTS_SHOWN = 3;
+ private static final int MIN_SHARED_SEGMENTS = 2;
+ private static final int INDENT_WIDTH = 4;
+
+ private BundleResolutionAnalyzer() {
+ }
+
+ /**
+ * Why a requirement cannot be met.
+ */
+ public enum Reason {
+
+ /** No bundle in the framework declares a capability under the name the requirement asks for. */
+ NOT_DECLARED,
+
+ /** The name is declared, but no declaration satisfies the filter. Nearly always a version range that does not overlap. */
+ DECLARATIONS_REJECTED,
+
+ /** A declaration satisfies the filter, but every bundle providing it is itself unresolved. A step, never a root cause. */
+ WAITING_ON_PROVIDER,
+
+ /** Every mandatory requirement can be satisfied, yet the bundle is not resolved. Look for a uses constraint conflict. */
+ NO_UNSATISFIED_REQUIREMENT
+ }
+
+ /**
+ * How a declared name relates to the one a requirement asked for and could not find.
+ */
+ public enum Relation {
+
+ /** The declared name is a parent of the wanted one, so the requirement is for a subpackage nobody exports. */
+ ANCESTOR,
+
+ /** The declared name sits below the wanted one. */
+ DESCENDANT,
+
+ /** The declared name shares a parent with the wanted one. */
+ SIBLING,
+
+ /** Nothing is related by name, and the namespace is small enough to simply list what it does hold. */
+ SAME_NAMESPACE
+ }
+
+ /**
+ * A name that is declared, offered when the one asked for is not. The point is to distinguish a requirement on something that never
+ * existed from a requirement on an unexported corner of something that does.
+ */
+ public record NearestName(String name, Relation relation, int distance, List<Candidate> declaredBy) {
+
+ @Override
+ public String toString() {
+ return name + " (" + relation + ")";
+ }
+ }
+
+ /**
+ * A capability that was considered for a requirement, with the bundle declaring it.
+ */
+ public record Candidate(Bundle bundle, BundleCapability capability) {
+
+ @Override
+ public String toString() {
+ return describe(bundle) + " declares " + attributesOf(capability);
+ }
+ }
+
+ /**
+ * One hop along the path to a root cause: a bundle and the requirement of it that could not be met.
+ */
+ public record Step(Bundle bundle, BundleRequirement requirement) {
+
+ @Override
+ public String toString() {
+ if (requirement == null) {
+ return describe(bundle);
+ }
+
+ return describe(bundle) + " requires " + describe(requirement);
+ }
+ }
+
+ /**
+ * A requirement nothing can satisfy, and the shortest path from the bundle that was asked about to the bundle declaring it. The last
+ * step of the path is the failing one; the steps before it are the bundles that are only waiting on it.
+ */
+ public record RootCause(List<Step> path, Reason reason, List<Candidate> candidates, List<NearestName> nearest,
+ List<Bundle> privateIn) {
+
+ public Bundle bundle() {
+ return path.get(path.size() - 1).bundle();
+ }
+
+ public BundleRequirement requirement() {
+ return path.get(path.size() - 1).requirement();
+ }
+
+ /**
+ * One line, so that printing a list of these stays readable. {@link #explain(BundleContext, Bundle)} is the rendering meant for a
+ * person to read.
+ */
+ @Override
+ public String toString() {
+ StringBuilder text = new StringBuilder(160);
+
+ for (Step step : path) {
+ if (!text.isEmpty()) {
+ text.append(" -> ");
+ }
+ text.append(step.bundle().getSymbolicName()).append(" [").append(step.bundle().getBundleId()).append(']');
+ }
+
+ if (requirement() != null) {
+ text.append(" requires ").append(describe(requirement()));
+ }
+
+ return text.append(" - ").append(summaryOf(reason)).toString();
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Public API
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Explains a single bundle, which is what belongs in the message of a failure to start it.
+ *
+ * @param bundleContext Any valid context, used only to enumerate the installed bundles.
+ * @param bundle The bundle that would not resolve.
+ * @return A short report, a handful of lines, naming the requirement that nothing can satisfy.
+ */
+ public static String explain(BundleContext bundleContext, Bundle bundle) {
+ if (isResolved(bundle)) {
+ return describe(bundle) + " is resolved.\n";
+ }
+
+ List<RootCause> rootCauses = findRootCauses(bundleContext, bundle);
+ if (rootCauses.isEmpty()) {
+ return describe(bundle) + " declares no mandatory requirement that cannot be met.\n";
+ }
+
+ StringBuilder reportBuilder = new StringBuilder(1024);
+ reportBuilder.append(describe(bundle)).append(" cannot resolve.\n");
+
+ for (RootCause rootCause : rootCauses) {
+ reportBuilder.append('\n');
+ append(rootCause, 1, reportBuilder);
+ }
+
+ return reportBuilder.toString();
+ }
+
+ /**
+ * Answers whether a bundle's jar holds a package without exporting it - a private package, in bnd's terms. That is the difference
+ * between a dependency on something that was never shipped and a dependency on an unexported corner of something that was, and the
+ * two have completely different fixes.
+ *
+ * Only the bundle's own jar is searched. A package embedded in a nested jar on the Bundle-ClassPath is not found, so a false answer
+ * means "not seen", never "definitely absent".
+ *
+ * @param bundle The bundle to look inside.
+ * @param packageName The package to look for, in dotted form.
+ * @return True when the jar holds at least one entry directly in that package. False on anything unreadable, because a guess here
+ * would be worse than silence.
+ */
+ public static boolean containsPackage(Bundle bundle, String packageName) {
+ String path = packageName.replace(SEGMENT_SEPARATOR, PATH_SEPARATOR);
+
+ try {
+ // getEntryPaths does not attempt to resolve the bundle the way findEntries does. We are reporting a resolution failure
+ // already, so provoking another one on the side would be careless.
+ Enumeration<String> entries = bundle.getEntryPaths(path);
+ if (entries != null && entries.hasMoreElements()) {
+ return true;
+ }
+
+ // getEntryPaths works off the file entries, so it finds the package even in a jar that carries no directory entries. The
+ // directory lookup only adds the case of a package directory that exists but holds nothing.
+ return bundle.getEntry(path + PATH_SEPARATOR) != null;
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Finds the requirements that nothing in the framework can satisfy, reachable from a bundle, nearest first.
+ * <p>
+ * The search is breadth first over the bundles that a failing requirement leads to, and it stops descending as soon as a bundle
+ * explains itself, so the path returned is the shortest one. Every bundle is examined at most once.
+ *
+ * @param bundleContext Any valid context, used only to enumerate the installed bundles.
+ * @param bundle The bundle to start from.
+ * @return The root causes, nearest first, never more than a handful. Empty when the bundle has no current revision.
+ */
+ public static List<RootCause> findRootCauses(BundleContext bundleContext, Bundle bundle) {
+ return findRootCauses(bundle, CapabilityIndex.of(bundleContext));
+ }
+
+ /**
+ * Summarises the whole framework: the distinct requirements that nothing can satisfy, the bundles blocked by each of them, and a count
+ * of the bundles that are only waiting on those. This replaces describing every unresolved bundle, which says the same thing once per
+ * bundle.
+ *
+ * @param bundleContext Any valid context, used only to enumerate the installed bundles.
+ * @return A report of at most a few dozen lines however large the cascade is.
+ */
+ public static String explainUnresolvedBundles(BundleContext bundleContext) {
+ CapabilityIndex index = CapabilityIndex.of(bundleContext);
+
+ List<Bundle> unresolved = new ArrayList<>();
+ for (Bundle bundle : bundleContext.getBundles()) {
+ if (!isResolved(bundle) && bundle.getState() != Bundle.UNINSTALLED) {
+ unresolved.add(bundle);
+ }
+ }
+
+ if (unresolved.isEmpty()) {
+ return "All installed bundles are resolved.\n";
+ }
+
+ // Bundles that fail on their own are the causes. The rest are either waiting on one of those, or have nothing wrong with them.
+ Map<String, List<RootCause>> causes = new LinkedHashMap<>();
+ int waiting = 0;
+ int satisfiable = 0;
+
+ for (Bundle bundle : unresolved) {
+ List<Verdict> verdicts = verdicts(bundle, index);
+ if (verdicts.isEmpty()) {
+ satisfiable++;
+ continue;
+ }
+
+ List<Verdict> terminal = verdicts.stream().filter(verdict -> verdict.reason() != Reason.WAITING_ON_PROVIDER).toList();
+ if (terminal.isEmpty()) {
+ waiting++;
+ continue;
+ }
+
+ for (Verdict verdict : terminal) {
+ RootCause rootCause = new RootCause(List.of(new Step(bundle, verdict.requirement())), verdict.reason(),
+ verdict.candidates(), verdict.nearest(), verdict.privateIn());
+ causes.computeIfAbsent(headline(rootCause), headline -> new ArrayList<>()).add(rootCause);
+ }
+ }
+
+ return summarise(bundleContext, unresolved.size(), causes, waiting, satisfiable);
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Search
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ /** A requirement of one bundle that could not be met, and the capabilities that were considered for it. */
+ private record Verdict(BundleRequirement requirement, Reason reason, List<Candidate> candidates, List<NearestName> nearest,
+ List<Bundle> privateIn) {
+ }
+
+ /** A bundle still to examine, with the path that led to it. */
+ private record Frontier(List<Step> path, Bundle bundle) {
+ }
+
+ private static List<RootCause> findRootCauses(Bundle bundle, CapabilityIndex index) {
+ List<RootCause> rootCauses = new ArrayList<>();
+
+ Set<Long> visited = new LinkedHashSet<>();
+ visited.add(bundle.getBundleId());
+
+ Deque<Frontier> frontier = new ArrayDeque<>();
+ frontier.add(new Frontier(List.of(), bundle));
+
+ while (!frontier.isEmpty() && rootCauses.size() < MAX_ROOT_CAUSES) {
+ Frontier current = frontier.removeFirst();
+
+ if (current.bundle().adapt(BundleRevision.class) == null) {
+ continue;
+ }
+
+ List<Verdict> verdicts = verdicts(current.bundle(), index);
+ if (verdicts.isEmpty()) {
+ rootCauses.add(new RootCause(extend(current, null), Reason.NO_UNSATISFIED_REQUIREMENT, List.of(), List.of(), List.of()));
+ continue;
+ }
+
+ List<Verdict> terminal = verdicts.stream().filter(verdict -> verdict.reason() != Reason.WAITING_ON_PROVIDER).toList();
+ if (!terminal.isEmpty()) {
+ // This bundle explains itself, so there is nothing to gain by walking past it.
+ for (Verdict verdict : terminal) {
+ rootCauses.add(new RootCause(extend(current, verdict.requirement()), verdict.reason(), verdict.candidates(),
+ verdict.nearest(), verdict.privateIn()));
+ }
+ continue;
+ }
+
+ for (Verdict verdict : verdicts) {
+ for (Candidate candidate : verdict.candidates()) {
+ if (visited.add(candidate.bundle().getBundleId())) {
+ frontier.addLast(new Frontier(extend(current, verdict.requirement()), candidate.bundle()));
+ }
+ }
+ }
+ }
+
+ return rootCauses;
+ }
+
+ private static List<Step> extend(Frontier frontier, BundleRequirement requirement) {
+ List<Step> path = new ArrayList<>(frontier.path());
+ path.add(new Step(frontier.bundle(), requirement));
+
+ return List.copyOf(path);
+ }
+
+ private static List<Verdict> verdicts(Bundle bundle, CapabilityIndex index) {
+ BundleRevision revision = bundle.adapt(BundleRevision.class);
+ if (revision == null) {
+ return List.of();
+ }
+
+ List<Verdict> verdicts = new ArrayList<>();
+ for (BundleRequirement requirement : revision.getDeclaredRequirements(null)) {
+ if (!isMandatory(requirement)) {
+ continue;
+ }
+
+ Verdict verdict = judge(bundle, requirement, index);
+ if (verdict != null) {
+ verdicts.add(verdict);
+ }
+ }
+
+ return verdicts;
+ }
+
+ /**
+ * @return Null when the requirement can be met, otherwise why it cannot.
+ */
+ private static Verdict judge(Bundle bundle, BundleRequirement requirement, CapabilityIndex index) {
+ CapabilityIndex.Candidates candidates = index.candidatesFor(requirement);
+
+ List<Candidate> matching = new ArrayList<>();
+ List<Candidate> rejected = new ArrayList<>();
+
+ for (BundleCapability capability : candidates.capabilities()) {
+ Bundle provider = capability.getRevision().getBundle();
+
+ if (requirement.matches(capability)) {
+ // A bundle that exports what it imports - the substitutable export bnd generates for every export - satisfies itself.
+ if (isResolved(provider) || provider.getBundleId() == bundle.getBundleId()) {
+ return null;
+ }
+ matching.add(new Candidate(provider, capability));
+ } else if (candidates.sameName()) {
+ rejected.add(new Candidate(provider, capability));
+ }
+ }
+
+ if (!matching.isEmpty()) {
+ return new Verdict(requirement, Reason.WAITING_ON_PROVIDER, matching, List.of(), List.of());
+ }
+
+ if (!rejected.isEmpty()) {
+ return new Verdict(requirement, Reason.DECLARATIONS_REJECTED, rejected, List.of(), List.of());
+ }
+
+ List<NearestName> nearest = index.nearestTo(requirement);
+
+ return new Verdict(requirement, Reason.NOT_DECLARED, List.of(), nearest, index.findPrivateHolders(requirement, nearest));
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Capability index
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Every declared capability in the framework, indexed by namespace and by the name it carries, so that a requirement is only ever
+ * compared against the handful of capabilities that could conceivably match it.
+ * <p>
+ * Declared rather than wired, so that unresolved bundles - the whole point of this class - still contribute.
+ */
+ private static final class CapabilityIndex {
+
+ record Candidates(List<BundleCapability> capabilities, boolean sameName) {
+ }
+
+ private final Map<String, Map<String, List<BundleCapability>>> byName = new LinkedHashMap<>();
+ private final Map<String, List<BundleCapability>> byNamespace = new LinkedHashMap<>();
+ private final List<Bundle> bundles = new ArrayList<>();
+
+ static CapabilityIndex of(BundleContext bundleContext) {
+ CapabilityIndex index = new CapabilityIndex();
+
+ for (Bundle bundle : bundleContext.getBundles()) {
+ index.bundles.add(bundle);
+
+ BundleRevision revision = bundle.adapt(BundleRevision.class);
+ if (revision == null) {
+ continue;
+ }
+
+ for (BundleCapability capability : revision.getDeclaredCapabilities(null)) {
+ index.add(capability);
+ }
+ }
+
+ return index;
+ }
+
+ private void add(BundleCapability capability) {
+ String namespace = capability.getNamespace();
+ byNamespace.computeIfAbsent(namespace, key -> new ArrayList<>()).add(capability);
+
+ Object name = capability.getAttributes().get(namespace);
+ if (name != null) {
+ byName.computeIfAbsent(namespace, key -> new LinkedHashMap<>())
+ .computeIfAbsent(name.toString(), key -> new ArrayList<>())
+ .add(capability);
+ }
+ }
+
+ /**
+ * @return The capabilities worth comparing against the requirement, and whether they are narrowed to the name it asks for. When
+ * they are not, a capability that fails to match says nothing about how close it came.
+ */
+ Candidates candidatesFor(BundleRequirement requirement) {
+ String name = requiredName(requirement);
+ if (name == null) {
+ return new Candidates(byNamespace.getOrDefault(requirement.getNamespace(), List.of()), false);
+ }
+
+ Map<String, List<BundleCapability>> namespace = byName.getOrDefault(requirement.getNamespace(), Map.of());
+
+ return new Candidates(namespace.getOrDefault(name, List.of()), true);
+ }
+
+ /**
+ * Opens the jars most likely to hold the missing package and reports the ones that actually do.
+ *
+ * @param requirement The requirement whose name is declared nowhere.
+ * @param nearest What the name search turned up, whose declaring bundles are the first worth opening.
+ * @return The bundles holding the package without exporting it, at most a few.
+ */
+ List<Bundle> findPrivateHolders(BundleRequirement requirement, List<NearestName> nearest) {
+ String name = requiredName(requirement);
+ if (name == null) {
+ return List.of();
+ }
+
+ List<Bundle> holders = new ArrayList<>();
+ for (Bundle suspect : suspects(name, nearest)) {
+ if (holders.size() == MAX_SUSPECTS_SHOWN) {
+ break;
+ }
+
+ if (containsPackage(suspect, name)) {
+ holders.add(suspect);
+ }
+ }
+
+ return List.copyOf(holders);
+ }
+
+ /**
+ * Whoever declares the nearest name is the obvious jar to open. After that, any bundle whose symbolic name shares a long prefix
+ * with the package, which is the only lead left when a whole subtree is exported nowhere.
+ */
+ private List<Bundle> suspects(String name, List<NearestName> nearest) {
+ Map<Long, Bundle> suspects = new LinkedHashMap<>();
+
+ for (NearestName nearestName : nearest) {
+ for (Candidate candidate : nearestName.declaredBy()) {
+ suspects.putIfAbsent(candidate.bundle().getBundleId(), candidate.bundle());
+ }
+ }
+
+ List<Bundle> byPrefix = new ArrayList<>();
+ for (Bundle bundle : bundles) {
+ if (bundle.getSymbolicName() != null && sharedSegments(bundle.getSymbolicName(), name) >= MIN_SHARED_SEGMENTS) {
+ byPrefix.add(bundle);
+ }
+ }
+ byPrefix.sort(Comparator.comparingInt((Bundle bundle) -> sharedSegments(bundle.getSymbolicName(), name)).reversed());
+
+ for (Bundle bundle : byPrefix) {
+ if (suspects.size() == MAX_SUSPECTS_PROBED) {
+ break;
+ }
+
+ suspects.putIfAbsent(bundle.getBundleId(), bundle);
+ }
+
+ return new ArrayList<>(suspects.values());
+ }
+
+ private static int sharedSegments(String left, String right) {
+ String[] leftSegments = left.split("\\.");
+ String[] rightSegments = right.split("\\.");
+
+ int shared = 0;
+ while (shared < leftSegments.length && shared < rightSegments.length && leftSegments[shared].equals(rightSegments[shared])) {
+ shared++;
+ }
+
+ return shared;
+ }
+
+ /**
+ * Looks for a declared name close to the one a requirement asked for and did not find. A declared ancestor is the strongest
+ * signal there is: it means the bundle imports a corner of something that is exported, which is what an import generated for an
+ * unexported internal package looks like.
+ *
+ * @param requirement The requirement whose name is declared nowhere.
+ * @return At most a few names, all related the same way, nearest relation first. Empty when nothing is close.
+ */
+ List<NearestName> nearestTo(BundleRequirement requirement) {
+ String name = requiredName(requirement);
+ Map<String, List<BundleCapability>> declared = byName.getOrDefault(requirement.getNamespace(), Map.of());
+
+ if (name == null || declared.isEmpty()) {
+ return List.of();
+ }
+
+ for (int dot = name.lastIndexOf(SEGMENT_SEPARATOR); dot > 0; dot = name.lastIndexOf(SEGMENT_SEPARATOR, dot - 1)) {
+ String ancestor = name.substring(0, dot);
+ if (declared.containsKey(ancestor)) {
+ return List.of(toNearestName(ancestor, Relation.ANCESTOR, segments(name) - segments(ancestor),
+ declared.get(ancestor)));
+ }
+ }
+
+ String below = name + SEGMENT_SEPARATOR;
+ List<NearestName> descendants = collect(declared, Relation.DESCENDANT, name, candidate -> candidate.startsWith(below));
+ if (!descendants.isEmpty()) {
+ return descendants;
+ }
+
+ int lastDot = name.lastIndexOf(SEGMENT_SEPARATOR);
+ if (lastDot > 0) {
+ String parent = name.substring(0, lastDot + 1);
+ List<NearestName> siblings = collect(declared, Relation.SIBLING, name,
+ candidate -> candidate.startsWith(parent) && candidate.indexOf(SEGMENT_SEPARATOR, parent.length()) < 0);
+ if (!siblings.isEmpty()) {
+ return siblings;
+ }
+ }
+
+ // Namespaces like osgi.ee hold a handful of names with no hierarchy at all, so listing them is the useful answer.
+ if (declared.size() <= MAX_NEAREST_SHOWN) {
+ return collect(declared, Relation.SAME_NAMESPACE, name, candidate -> true);
+ }
+
+ return List.of();
+ }
+
+ private static int segments(String name) {
+ int count = 1;
+ for (int index = name.indexOf(SEGMENT_SEPARATOR); index >= 0; index = name.indexOf(SEGMENT_SEPARATOR, index + 1)) {
+ count++;
+ }
+
+ return count;
+ }
+
+ private static List<NearestName> collect(Map<String, List<BundleCapability>> declared, Relation relation, String name,
+ Predicate<String> matches) {
+
+ List<NearestName> nearest = new ArrayList<>();
+ for (Map.Entry<String, List<BundleCapability>> entry : declared.entrySet()) {
+ if (nearest.size() == MAX_NEAREST_SHOWN) {
+ return nearest;
+ }
+
+ if (matches.test(entry.getKey())) {
+ nearest.add(toNearestName(entry.getKey(), relation, Math.abs(segments(entry.getKey()) - segments(name)),
+ entry.getValue()));
+ }
+ }
+
+ return nearest;
+ }
+
+ private static NearestName toNearestName(String name, Relation relation, int distance, List<BundleCapability> capabilities) {
+ List<Candidate> declaredBy = new ArrayList<>();
+ for (BundleCapability capability : capabilities) {
+ if (declaredBy.size() == MAX_NEAREST_SHOWN) {
+ break;
+ }
+ declaredBy.add(new Candidate(capability.getRevision().getBundle(), capability));
+ }
+
+ return new NearestName(name, relation, distance, List.copyOf(declaredBy));
+ }
+
+ /**
+ * Reads the name a requirement asks for out of the equality assertion in its filter directive.
+ * <p>
+ * The assertion is matched whole, parentheses included. Matching it loosely is what made an export of org.jboss.weld.annotated look
+ * like a near miss for a requirement on org.jboss.weld.annotated.enhanced, and with a prefix of the wanted name almost always
+ * present in a package hierarchy, that hid every genuinely absent capability behind a bundle that had nothing to do with it.
+ *
+ * @return The name, or null when the filter does not assert exactly one, in which case the caller falls back to the whole
+ * namespace. A disjunction over several names lands here.
+ */
+ private static String requiredName(BundleRequirement requirement) {
+ String filter = requirement.getDirectives().get(REQUIREMENT_FILTER_DIRECTIVE);
+ if (filter == null) {
+ return null;
+ }
+
+ String assertion = "(" + requirement.getNamespace() + "=";
+
+ int start = filter.indexOf(assertion);
+ if (start < 0 || filter.indexOf(assertion, start + 1) >= 0) {
+ return null;
+ }
+
+ int end = filter.indexOf(')', start + assertion.length());
+ if (end < 0) {
+ return null;
+ }
+
+ return filter.substring(start + assertion.length(), end);
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Rendering
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ private static void append(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+ List<Step> path = rootCause.path();
+
+ for (int index = 0; index < path.size(); index++) {
+ Step step = path.get(index);
+ String arrow = index == 0 ? "" : "-> ";
+
+ printLine(reportBuilder, indent, arrow + describe(step.bundle()));
+ if (step.requirement() != null) {
+ printLine(reportBuilder, indent + 1, "requires " + describe(step.requirement()));
+ }
+ }
+
+ printLine(reportBuilder, indent + 1, describe(rootCause.reason()));
+ appendCandidates(rootCause, indent + 2, reportBuilder);
+ appendPresence(rootCause, indent + 1, reportBuilder);
+ }
+
+ /**
+ * Finding the package inside a jar answers the question outright, so the nearest name is not worth printing alongside it.
+ */
+ private static void appendPresence(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+ if (rootCause.privateIn().isEmpty()) {
+ appendNearest(rootCause, indent, reportBuilder);
+ return;
+ }
+
+ printLine(reportBuilder, indent, "but it is present, unexported, in:");
+ for (Bundle bundle : rootCause.privateIn()) {
+ printLine(reportBuilder, indent + 1, describe(bundle));
+ }
+ }
+
+ private static void appendNearest(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+ for (NearestName nearest : rootCause.nearest()) {
+ printLine(reportBuilder, indent, describe(nearest) + " " + nearest.name());
+
+ for (Candidate candidate : nearest.declaredBy()) {
+ printLine(reportBuilder, indent + 1, "declared by " + describe(candidate.bundle()));
+ }
+ }
+ }
+
+ private static void appendCandidates(RootCause rootCause, int indent, StringBuilder reportBuilder) {
+ int shown = 0;
+ for (Candidate candidate : rootCause.candidates()) {
+ if (shown == MAX_CANDIDATES_SHOWN) {
+ printLine(reportBuilder, indent, (rootCause.candidates().size() - shown) + " further declarations not shown");
+ return;
+ }
+
+ printLine(reportBuilder, indent, candidate.toString());
+ shown++;
+ }
+ }
+
+ private static String summarise(BundleContext bundleContext, int unresolved, Map<String, List<RootCause>> causes, int waiting,
+ int satisfiable) {
+ StringBuilder reportBuilder = new StringBuilder(2048);
+ reportBuilder.append(unresolved).append(" of ").append(bundleContext.getBundles().length)
+ .append(" installed bundles are unresolved.\n");
+
+ if (causes.isEmpty()) {
+ reportBuilder.append("\nNo bundle carries a requirement that nothing can satisfy, "
+ + "which points at a uses constraint rather than a missing capability.\n");
+
+ return reportBuilder.toString();
+ }
+
+ reportBuilder.append('\n').append(causes.size()).append(causes.size() == 1 ? " root cause:\n" : " distinct root causes:\n");
+
+ int causesShown = 0;
+ for (Map.Entry<String, List<RootCause>> cause : causes.entrySet()) {
+ if (causesShown == MAX_CAUSES_SHOWN) {
+ reportBuilder.append('\n').append(causes.size() - causesShown).append(" further root causes not shown.\n");
+ break;
+ }
+ causesShown++;
+
+ List<RootCause> blocked = cause.getValue();
+
+ reportBuilder.append('\n');
+ printLine(reportBuilder, 1, cause.getKey());
+ appendCandidates(blocked.get(0), 2, reportBuilder);
+ appendPresence(blocked.get(0), 2, reportBuilder);
+
+ int shown = 0;
+ for (RootCause rootCause : blocked) {
+ if (shown == MAX_BUNDLES_PER_CAUSE) {
+ printLine(reportBuilder, 2, "and " + (blocked.size() - shown) + " further bundles");
+ break;
+ }
+
+ printLine(reportBuilder, 2, "blocks " + describe(rootCause.bundle()));
+ shown++;
+ }
+ }
+
+ if (waiting > 0) {
+ reportBuilder.append('\n').append(waiting)
+ .append(waiting == 1 ? " further bundle is unresolved only because it needs one of the bundles above.\n"
+ : " further bundles are unresolved only because they need one of the bundles above.\n");
+ }
+
+ if (satisfiable > 0) {
+ reportBuilder.append('\n').append(satisfiable)
+ .append(satisfiable == 1 ? " further bundle has every mandatory requirement satisfiable, so it is"
+ : " further bundles have every mandatory requirement satisfiable, so they are")
+ .append(" unresolved only because nothing has needed them yet, or because of a uses constraint.\n");
+ }
+
+ return reportBuilder.toString();
+ }
+
+ private static String headline(RootCause rootCause) {
+ return describe(rootCause.requirement()) + " - " + describe(rootCause.reason());
+ }
+
+ private static String describe(Bundle bundle) {
+ return bundle.getSymbolicName() + " " + bundle.getVersion() + " [" + bundle.getBundleId() + "] " + toStateName(bundle);
+ }
+
+ /**
+ * Describes a requirement by the filter directive the framework stored, rendered the same way {@link FelixPrettyPrinter} renders the
+ * one in the resolver message, so that the two reports agree rather than each inventing its own notation.
+ */
+ private static String describe(BundleRequirement requirement) {
+ String filter = requirement.getDirectives().get(REQUIREMENT_FILTER_DIRECTIVE);
+ if (filter == null) {
+ return requirement.getNamespace() + " " + requirement.getAttributes();
+ }
+
+ return FelixPrettyPrinter.formatFilter(filter);
+ }
+
+ /** The compact wording, for a reason that has to share a line with everything else. */
+ private static String summaryOf(Reason reason) {
+ return switch (reason) {
+ case NOT_DECLARED -> "not declared by any bundle";
+ case DECLARATIONS_REJECTED -> "declared, but no declaration satisfies it";
+ case WAITING_ON_PROVIDER -> "every bundle providing it is unresolved";
+ case NO_UNSATISFIED_REQUIREMENT -> "every requirement satisfiable, look for a uses constraint";
+ };
+ }
+
+ /**
+ * How far away the declared name is decides how much it is worth. A parent one level up says the requirement is for an unexported
+ * corner of a bundle that is right there; an ancestor three levels up says little more than that the tree exists.
+ */
+ private static String describe(NearestName nearest) {
+ return switch (nearest.relation()) {
+ case ANCESTOR -> nearest.distance() == 1 ? "but its parent is declared:"
+ : "but an ancestor " + nearest.distance() + " levels up is declared:";
+ case DESCENDANT -> nearest.distance() == 1 ? "but a name directly below it is declared:"
+ : "but a name " + nearest.distance() + " levels below is declared:";
+ case SIBLING -> "but a name alongside it is declared:";
+ case SAME_NAMESPACE -> "what this namespace does hold:";
+ };
+ }
+
+ private static String describe(Reason reason) {
+ return switch (reason) {
+ case NOT_DECLARED -> "no bundle in the framework declares that";
+ case DECLARATIONS_REJECTED -> "declared, but no declaration satisfies the requirement:";
+ case WAITING_ON_PROVIDER -> "a declaration satisfies it, but every bundle providing it is unresolved:";
+ case NO_UNSATISFIED_REQUIREMENT -> "every mandatory requirement can be satisfied - look for a uses constraint conflict";
+ };
+ }
+
+ /** The attributes of a capability without the one carrying its name, which the requirement line above already showed. */
+ private static String attributesOf(BundleCapability capability) {
+ Map<String, Object> attributes = new LinkedHashMap<>(capability.getAttributes());
+ attributes.remove(capability.getNamespace());
+
+ return attributes.toString();
+ }
+
+ private static void printLine(StringBuilder reportBuilder, int indent, String text) {
+ reportBuilder.append(" ".repeat(indent * INDENT_WIDTH)).append(text).append('\n');
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Model helpers
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * A requirement only blocks resolution when it is effective at resolve time and neither optional nor dynamic. Skipping the rest keeps
+ * the report free of the osgi.service requirements that Declarative Services adds, which are never resolved against.
+ */
+ private static boolean isMandatory(BundleRequirement requirement) {
+ Map<String, String> directives = requirement.getDirectives();
+
+ if (!EFFECTIVE_RESOLVE.equals(directives.getOrDefault(REQUIREMENT_EFFECTIVE_DIRECTIVE, EFFECTIVE_RESOLVE))) {
+ return false;
+ }
+
+ String resolution = directives.getOrDefault(REQUIREMENT_RESOLUTION_DIRECTIVE, RESOLUTION_MANDATORY);
+
+ return !RESOLUTION_OPTIONAL.equals(resolution) && !RESOLUTION_DYNAMIC.equals(resolution);
+ }
+
+ private static boolean isResolved(Bundle bundle) {
+ return (bundle.getState() & (Bundle.RESOLVED | Bundle.STARTING | Bundle.STOPPING | Bundle.ACTIVE)) != 0;
+ }
+
+ private static String toStateName(Bundle bundle) {
+ return switch (bundle.getState()) {
+ case Bundle.UNINSTALLED -> "UNINSTALLED";
+ case Bundle.INSTALLED -> "INSTALLED";
+ case Bundle.RESOLVED -> "RESOLVED";
+ case Bundle.STARTING -> "STARTING";
+ case Bundle.STOPPING -> "STOPPING";
+ case Bundle.ACTIVE -> "ACTIVE";
+ default -> "UNKNOWN";
+ };
+ }
+}
\ No newline at end of file
diff --git a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java
index 04d8865..21f8404 100644
--- a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java
+++ b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinter.java
@@ -22,9 +22,7 @@
import java.io.InputStreamReader;
import java.net.URL;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.Enumeration;
-import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -35,300 +33,826 @@
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleException;
+import org.osgi.framework.Filter;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.Version;
import org.osgi.framework.wiring.BundleRevision;
import org.osgi.resource.Capability;
import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.osgi.framework.namespace.PackageNamespace.CAPABILITY_VERSION_ATTRIBUTE;
import static org.osgi.framework.namespace.PackageNamespace.PACKAGE_NAMESPACE;
/**
- * Tools for obtaining readable information from the {@link BundleException}
+ * Tools for obtaining readable information from the {@link BundleException} thrown by the Felix resolver.
+ * <p>
+ * The message format of the resolver is not specified anywhere, so everything in this class is best effort. The one hard rule it follows is
+ * that it never destroys information: when a message cannot be understood completely, the original text is appended to the output instead of
+ * being silently dropped.
+ * <p>
+ * For a diagnosis that does not depend on the message format at all, see {@code BundleResolutionAnalyzer}, which asks the resolver model
+ * directly instead of reading its prose.
*/
-public class FelixPrettyPrinter {
+public final class FelixPrettyPrinter {
- private static final Pattern BUNDLE_PATTERN = Pattern.compile("\\[(\\d+)\\]", Pattern.MULTILINE);
+ private static final Pattern BUNDLE_ID_PATTERN = Pattern.compile("\\[(\\d+)\\]");
- public static String prettyPrintFelixMessage(BundleContext context, final String bundleMessage) {
- final String prettyMessage = prettyPrintExceptionMessage(bundleMessage);
+ /** Comparison operators of an LDAP filter, longest first so that {@code >=} wins over {@code =}. */
+ private static final Pattern FILTER_OPERATOR_PATTERN = Pattern.compile(">=|<=|~=|=");
- final StringBuilder bundleBuilder = new StringBuilder(1024);
- bundleBuilder.append(prettyMessage);
+ private static final String UNABLE_TO_RESOLVE = "Unable to resolve";
+ private static final String MISSING_REQUIREMENT = "missing requirement";
+ private static final String CAUSED_BY = "caused by:";
+ private static final String REVISION_MARKER = "(R ";
- List<Long> bundleIDs = new ArrayList<>();
+ /** Dropped when rendering a filter, so that osgi.wiring.package reads as package and osgi.wiring.host as host. */
+ private static final String WIRING_NAMESPACE_PREFIX = "osgi.wiring.";
- bundleIDs.addAll(addExportingBundles(context, prettyMessage, bundleBuilder));
- bundleIDs.addAll(findBundleIds(prettyMessage));
+ private static final String ORIGINAL_MESSAGE_MARKER = "--- original message, not fully understood by the pretty printer ---";
- if (!bundleIDs.isEmpty()) {
- for (Long bundleId : bundleIDs) {
- Bundle bundle = context.getBundle(bundleId);
- if (bundle != null) {
- bundleBuilder.append('[').append(bundleId).append("] \n");
- bundleBuilder.append("jar = ").append(bundle.getLocation());
- tryAddPomProperties(bundle, bundleBuilder);
- bundleBuilder.append('\n');
- }
- }
+ private static final int INDENT_WIDTH = 4;
+
+ /** A shaded bundle can carry dozens of Maven descriptors, which would drown the actual error. */
+ private static final int MAX_POM_PROPERTIES_PER_BUNDLE = 4;
+
+ public static void main(String[] args) {
+ System.out.println(prettyPrintExceptionMessage("Unable to resolve org.glassfish.main.hk2.config-types [138](R 138.0): missing requirement [org.glassfish.main.hk2.config-types [138](R 138.0)] osgi.wiring.package; (&(osgi.wiring.package=org.jvnet.hk2.config)(version>=9.0.0)(!(version>=10.0.0))) [caused by: Unable to resolve org.glassfish.main.hk2-config-generator [159](R 159.0): missing requirement [org.glassfish.main.hk2-config-generator [159](R 159.0)] osgi.wiring.package; (&(osgi.wiring.package=org.hibernate.validator)(version>=9.1.0)(!(version>=10.0.0))) [caused by: Unable to resolve org.hibernate.validator [262](R 262.0): missing requirement [org.hibernate.validator [262](R 262.0)] osgi.wiring.package; (&(osgi.wiring.package=jakarta.validation)(version>=3.1.1)(!(version>=4.0.0)))]] Unresolved requirements: [[org.glassfish.main.hk2.config-types [138](R 138.0)] osgi.wiring.package; (&(osgi.wiring.package=org.jvnet.hk2.config)(version>=9.0.0)(!(version>=10.0.0)))]"));
+ }
+
+ private FelixPrettyPrinter() {
+ }
+
+ /**
+ * Renders a resolver message and appends everything known about the bundles it mentions, plus the bundles that export the packages that
+ * could not be wired.
+ *
+ * @param bundleContext The context used to resolve bundle ids and to look for exporters. Must not be null.
+ * @param bundleMessage The raw message of the {@link BundleException}. May be null or empty, in which case it is returned unchanged.
+ * @return A multiline, human readable rendering of the message.
+ */
+ public static String prettyPrintFelixMessage(BundleContext bundleContext, String bundleMessage) {
+ if (bundleMessage == null || bundleMessage.isEmpty()) {
+ return bundleMessage;
}
- return bundleBuilder.toString();
+ try {
+ List<ResolutionFailure> failures = parseFailures(bundleMessage);
+
+ StringBuilder messageBuilder = new StringBuilder(1024);
+ messageBuilder.append(format(failures, bundleMessage));
+
+ Set<Long> bundleIds = new LinkedHashSet<>();
+ bundleIds.addAll(appendExporters(bundleContext, failures, messageBuilder));
+ bundleIds.addAll(findBundleIds(messageBuilder.toString()));
+
+ for (Long bundleId : bundleIds) {
+ Bundle bundle = bundleContext.getBundle(bundleId);
+ if (bundle != null) {
+ appendBundleInfo(bundle, messageBuilder);
+ }
+ }
+
+ return messageBuilder.toString();
+ } catch (RuntimeException e) {
+ // We are usually formatting another failure already - never turn that into a second one.
+ return bundleMessage;
+ }
}
/**
* Prints exception messages from Felix bundle classloading in a more human readable way.
*
- * @param message - error message from the exception
- * @return multiline human readable string
+ * @param message The error message from the exception. May be null or empty, in which case it is returned unchanged.
+ * @return A multiline human readable string, ending with the original message when the parse was incomplete.
*/
- public static String prettyPrintExceptionMessage(final String message) {
- StringBuilder messageBuilder = new StringBuilder(256);
+ public static String prettyPrintExceptionMessage(String message) {
+ if (message == null || message.isEmpty()) {
+ return message;
+ }
+
try {
- int index = message.indexOf("Unable to resolve");
- int indent = 0;
- while (index >= 0) {
- printLn(messageBuilder, indent, "Unable to resolve");
- index += "Unable to resolve".length();
-
- int index2 = message.indexOf("missing requirement", index);
- if (index2 >= 0) {
-
- indent++;
-
- // Module name would be e.g.
- // org.glassfish.server.internal.batch.glassfish-batch-connector [103](R 103.0):
- String module = message.substring(index, index2);
-
- // Remove the duplicate number
- if (module.contains("(R")) {
- module = module.substring(0, module.indexOf("(R"));
- }
-
- printLn(messageBuilder, indent, module);
- printLn(messageBuilder, indent, "missing requirement");
-
- index = index2 + "missing requirement".length();
-
- // In GlassFish and in a classloader the search is always for package, so we can
- // use that as a delimiter here
- int indexPackage = message.indexOf("osgi.wiring.package; ", index);
- int indexHost = message.indexOf("osgi.wiring.host; ", index);
-
- boolean hasPackage = indexPackage >= 0;
- boolean hasHost = indexHost >= 0;
-
- boolean isPackage = false;
- if (hasPackage && (!hasHost || indexPackage < indexHost)) {
- index = indexPackage;
- isPackage = true;
- } else if (hasHost) {
- index = indexHost;
- } else {
- index = -1;
- }
-
- if (index >= 0) {
-
- indent++;
-
- if (isPackage) {
-
- // Remainder of input now looks like this:
-
- // osgi.wiring.package; (&(osgi.wiring.package=org.glassfish.grizzly)(version>=2.4.0)(!(version>=3.0.0)))
-
- // Skip over "osgi.wiring.package; ", we're always searching for this so
- // no need to print it.
- index += "osgi.wiring.package; ".length();
-
- // Now extracting this:
- // "(&(osgi.wiring.package=org.glassfish.grizzly)(version>=2.4.0)(!(version>=3.0.0)))"
- index2 = message.indexOf(" ", index);
-
- String packageAndVersion = null;
- if (index2 != -1) {
- packageAndVersion = message.substring(index, index2);
- } else {
- packageAndVersion = message.substring(index);
- }
-
- // Make it a little less "cramped"
- // "(&(package=org.glassfish.grizzly) (version>=2.4.0) (!(version>=3.0.0)))"
- packageAndVersion = packageAndVersion.replace("osgi.wiring.package", "package");
- packageAndVersion = packageAndVersion.replace(")(", ") (");
- packageAndVersion = packageAndVersion.replace("=", " = ");
- packageAndVersion = packageAndVersion.replace("> =", " >=");
- packageAndVersion = packageAndVersion.replace("< =", " <=");
-
- // Remove outer braces
- // "&(package=org.glassfish.grizzly) (version>=2.4.0) (!(version>=3.0.0))"
- if (packageAndVersion.startsWith("(")) {
- packageAndVersion = packageAndVersion.substring(1);
- }
- if (packageAndVersion.endsWith(")")) {
- packageAndVersion = packageAndVersion.substring(0, packageAndVersion.length() - 1);
- }
-
- printLn(messageBuilder, indent, packageAndVersion);
- } else {
-
- // Remainder of input now looks like this:
-
- // osgi.wiring.host; (&(osgi.wiring.host=org.hibernate.validator)(bundle-version>=0.0.0)
-
- // Skip over "osgi.wiring.host; ", we're already searching for this so
- // no need to print it.
- index += "osgi.wiring.host; ".length();
-
- index2 = message.indexOf("]", index);
-
- String remainder = null;
- if (index2 != -1) {
- remainder = message.substring(index, index2);
- } else {
- remainder = message.substring(index);
- }
-
- printLn(messageBuilder, indent, remainder);
- }
-
- // If there's a "caused by:", print it and increase the indent
- index = message.indexOf("caused by: ", index2);
- if (index >= 0) {
-
- printLn(messageBuilder, indent, "caused by:");
-
- indent++;
- index += "caused by: ".length();
- }
-
- }
- }
-
- if (index2 == -1) {
- index = -1;
- } else {
- index = index2;
- index = message.indexOf("Unable to resolve", index);
- }
- }
- return messageBuilder.toString();
- } catch (Exception e) {
- // Usually we are processing another exception - if we failed, better return original.
+ return format(parseFailures(message), message);
+ } catch (RuntimeException e) {
+ // Usually we are processing another exception - if we failed, better return the original.
return message;
}
}
+ /**
+ * Appends the location and the Maven coordinates of a single bundle to an already rendered message.
+ *
+ * @param bundle The bundle to describe. May be null, in which case only the message is returned.
+ * @param prettyMessage The already rendered message.
+ * @return The message with the bundle information appended.
+ */
public static String addBundleInfo(Bundle bundle, String prettyMessage) {
- final StringBuilder bundleBuilder = new StringBuilder(1024);
- bundleBuilder.append('\n').append(prettyMessage);
+ StringBuilder messageBuilder = new StringBuilder(1024);
+ messageBuilder.append('\n').append(prettyMessage);
+
if (bundle != null) {
- bundleBuilder.append('[').append(bundle.getBundleId()).append("] \n");
- bundleBuilder.append("jar = ").append(bundle.getLocation());
- tryAddPomProperties(bundle, bundleBuilder);
- bundleBuilder.append('\n');
+ appendBundleInfo(bundle, messageBuilder);
}
- return bundleBuilder.toString();
+ return messageBuilder.toString();
}
- private static List<Long> addExportingBundles(BundleContext context, String prettyMessage, StringBuilder bundleBuilder) {
- Set<Bundle> exportingBundles = new HashSet<>();
- List<Long> bundleIDs = new ArrayList<>();
+ /**
+ * @param message The error message from the exception.
+ * @return The distinct bundle ids found in the message, in the order they appear. They are the numbers in square brackets.
+ */
+ public static List<Long> findBundleIds(String message) {
+ if (message == null || message.isEmpty()) {
+ return List.of();
+ }
- int lastPackageindex = prettyMessage.lastIndexOf("package = ");
- if (lastPackageindex != -1) {
- String lastPackage = prettyMessage.substring(lastPackageindex + "package = ".length(), prettyMessage.indexOf(")", lastPackageindex));
+ Set<Long> bundleIds = new LinkedHashSet<>();
+ Matcher bundleMatcher = BUNDLE_ID_PATTERN.matcher(message);
+ while (bundleMatcher.find()) {
+ bundleIds.add(Long.valueOf(bundleMatcher.group(1)));
+ }
- exportingBundles.addAll(findExporters(context, lastPackage));
+ return new ArrayList<>(bundleIds);
+ }
- if (exportingBundles.isEmpty()) {
- bundleBuilder.append("\nNo bundles found to export " + lastPackage + "\n");
- } else {
- bundleBuilder.append("\nThe following bundles export \"" + lastPackage + "\"\n");
- for (Bundle bundle : exportingBundles) {
- bundleIDs.add(bundle.getBundleId());
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Parsing
+ // -----------------------------------------------------------------------------------------------------------------------------------
- bundleBuilder.append(bundle.getSymbolicName())
- .append(" ")
- .append(bundle.getVersion())
- .append(" [")
- .append(bundle.getBundleId())
- .append("]")
- .append('\n')
- ;
+ /**
+ * A single "Unable to resolve" clause. The namespace and the filter are null when that part of the clause could not be understood, which
+ * is what drives the decision to append the original message.
+ */
+ private record ResolutionFailure(String module, String namespace, String filter, boolean isCause) {
+
+ boolean isComplete() {
+ return namespace != null && filter != null;
+ }
+ }
+
+ /**
+ * Splits the message into one clause per "Unable to resolve". Every clause is parsed strictly within its own bounds, so a clause can
+ * never borrow the requirement of the clause nesting below it.
+ */
+ private static List<ResolutionFailure> parseFailures(String message) {
+ List<ResolutionFailure> failures = new ArrayList<>();
+
+ int previousIndex = -1;
+ int index = message.indexOf(UNABLE_TO_RESOLVE);
+ while (index >= 0) {
+ int clauseStart = index + UNABLE_TO_RESOLVE.length();
+
+ int clauseEnd = message.indexOf(UNABLE_TO_RESOLVE, clauseStart);
+ if (clauseEnd < 0) {
+ clauseEnd = message.length();
+ }
+
+ // Only nest a clause when the resolver said it is a cause. Sibling clauses stay at the level of the one before them.
+ boolean isCause = previousIndex >= 0 && message.lastIndexOf(CAUSED_BY, index) > previousIndex;
+ failures.add(parseFailure(message, clauseStart, clauseEnd, isCause));
+
+ previousIndex = index;
+ index = message.indexOf(UNABLE_TO_RESOLVE, clauseStart);
+ }
+
+ return failures;
+ }
+
+ /**
+ * Parses one clause, which looks like this:
+ *
+ * <pre>
+ * org.glassfish.batch-connector [103](R 103.0): missing requirement [org.glassfish.batch-connector [103](R 103.0)] osgi.wiring.package;
+ * (&(osgi.wiring.package=jakarta.batch)(version>=2.1.0)(!(version>=3.0.0)))
+ * </pre>
+ */
+ private static ResolutionFailure parseFailure(String message, int start, int end, boolean isCause) {
+ int requirementIndex = message.indexOf(MISSING_REQUIREMENT, start);
+ if (requirementIndex < 0 || requirementIndex >= end) {
+ return new ResolutionFailure(cleanModule(message.substring(start, end)), null, null, isCause);
+ }
+
+ String module = cleanModule(message.substring(start, requirementIndex));
+
+ // Skip the revision reference that repeats the module, for example "[org.glassfish.batch-connector [103](R 103.0)]".
+ int namespaceStart = skipRevisionReference(message, requirementIndex + MISSING_REQUIREMENT.length(), end);
+
+ int namespaceEnd = message.indexOf(';', namespaceStart);
+ if (namespaceEnd < 0 || namespaceEnd >= end) {
+ return new ResolutionFailure(module, null, null, isCause);
+ }
+
+ // Any namespace is accepted here: osgi.wiring.package, osgi.wiring.host, osgi.wiring.bundle, osgi.ee, osgi.native, ...
+ String namespace = message.substring(namespaceStart, namespaceEnd).trim();
+
+ return new ResolutionFailure(module, namespace, extractFilter(message, namespaceEnd + 1, end), isCause);
+ }
+
+ private static int skipRevisionReference(String message, int start, int end) {
+ int index = start;
+ while (index < end && Character.isWhitespace(message.charAt(index))) {
+ index++;
+ }
+
+ if (index >= end || message.charAt(index) != '[') {
+ return index;
+ }
+
+ // The reference nests, so counting is the only way to find its end.
+ int depth = 0;
+ while (index < end) {
+ char character = message.charAt(index);
+ if (character == '[') {
+ depth++;
+ } else if (character == ']') {
+ depth--;
+ if (depth == 0) {
+ return index + 1;
}
}
- bundleBuilder.append('\n');
+ index++;
}
- return bundleIDs;
+ return start;
}
- private static List<Bundle> findExporters(BundleContext ctx, String packageName) {
- List<Bundle> exporters = new ArrayList<>();
+ /**
+ * Extracts a complete LDAP filter by balancing parentheses. Splitting on the first space, as we used to do, breaks on any filter that
+ * contains one and on any filter that ends the message.
+ */
+ private static String extractFilter(String message, int start, int end) {
+ int open = start;
+ while (open < end && message.charAt(open) != '(') {
+ open++;
+ }
- for (Bundle b : ctx.getBundles()) {
- BundleRevision rev = b.adapt(BundleRevision.class);
- if (rev == null) {
+ if (open >= end) {
+ return null;
+ }
+
+ int depth = 0;
+ for (int index = open; index < end; index++) {
+ char character = message.charAt(index);
+ if (character == '(') {
+ depth++;
+ } else if (character == ')') {
+ depth--;
+ if (depth == 0) {
+ return message.substring(open, index + 1);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /** Turns "org.glassfish.batch-connector [103](R 103.0): " into "org.glassfish.batch-connector [103]". */
+ private static String cleanModule(String module) {
+ String name = module.trim();
+
+ int revisionIndex = name.indexOf(REVISION_MARKER);
+ if (revisionIndex >= 0) {
+ name = name.substring(0, revisionIndex).trim();
+ }
+
+ if (name.endsWith(":")) {
+ name = name.substring(0, name.length() - 1).trim();
+ }
+
+ return name;
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Rendering
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ private static String format(List<ResolutionFailure> failures, String message) {
+ if (failures.isEmpty()) {
+ return message;
+ }
+
+ StringBuilder messageBuilder = new StringBuilder(256);
+
+ // Anything the framework put in front of the first clause is context we do not want to lose.
+ String prefix = message.substring(0, message.indexOf(UNABLE_TO_RESOLVE)).trim();
+ if (!prefix.isEmpty()) {
+ printLine(messageBuilder, 0, prefix);
+ }
+
+ int indent = 0;
+ for (ResolutionFailure failure : failures) {
+
+ // A cause belongs under the requirement that could not be met, which sits two levels below its own "Unable to resolve".
+ if (failure.isCause()) {
+ printLine(messageBuilder, indent + 2, CAUSED_BY);
+ indent += 3;
+ }
+
+ printLine(messageBuilder, indent, UNABLE_TO_RESOLVE);
+ printLine(messageBuilder, indent + 1, failure.module());
+
+ if (failure.namespace() != null) {
+ // The namespace is not repeated here, the filter below asserts on it and reads better for it.
+ printLine(messageBuilder, indent + 1, MISSING_REQUIREMENT);
+ }
+
+ if (failure.filter() != null) {
+ printLine(messageBuilder, indent + 2, formatFilter(failure.filter()));
+ }
+ }
+
+ if (!failures.stream().allMatch(ResolutionFailure::isComplete)) {
+ messageBuilder.append('\n').append(ORIGINAL_MESSAGE_MARKER).append('\n').append(message).append('\n');
+ }
+
+ return messageBuilder.toString();
+ }
+
+ /**
+ * Renders an LDAP filter the way it is normally read. The resolver prints prefix notation, so
+ * "(&(osgi.wiring.package=jakarta.batch)(version>=2.1.0)(!(version>=3.0.0)))" comes in as one dense line and goes out as
+ * "package = jakarta.batch & version >= 2.1.0 & !(version >= 3.0.0)".
+ *
+ * Package private so that {@code BundleResolutionAnalyzer} renders the filter it reads from a requirement directive the same way,
+ * rather than the two reports each inventing their own notation for the same thing.
+ *
+ * @param filter The raw filter as the resolver printed it.
+ * @return The filter in infix notation, or the filter merely spaced out when it does not follow the grammar.
+ */
+ static String formatFilter(String filter) {
+ String infix = new FilterParser(filter).parse();
+ if (infix != null) {
+ return infix;
+ }
+
+ // Not a filter we recognise. Show it spaced out rather than not at all.
+ return spaceOutFilter(filter);
+ }
+
+ private static String spaceOutFilter(String filter) {
+ String formatted = FILTER_OPERATOR_PATTERN.matcher(filter).replaceAll(" $0 ");
+ formatted = formatted.replace(")(", ") (");
+
+ return stripOuterParentheses(formatted).trim();
+ }
+
+ /**
+ * Shortens a namespace attribute, so that osgi.wiring.package reads as package and osgi.wiring.host as host. Namespaces without the
+ * wiring prefix, osgi.ee for instance, are left alone and stay recognisable.
+ */
+ private static String shortenNamespace(String namespace) {
+ if (namespace.startsWith(WIRING_NAMESPACE_PREFIX)) {
+ return namespace.substring(WIRING_NAMESPACE_PREFIX.length());
+ }
+
+ return namespace;
+ }
+
+ private static String stripOuterParentheses(String text) {
+ if (text.length() < 2 || text.charAt(0) != '(' || text.charAt(text.length() - 1) != ')') {
+ return text;
+ }
+
+ // Only strip when the first parenthesis is the one the last parenthesis closes, so that "(a) (b)" keeps both pairs and an
+ // unbalanced filter is handed back untouched.
+ int depth = 0;
+ for (int index = 0; index < text.length(); index++) {
+ char character = text.charAt(index);
+ if (character == '(') {
+ depth++;
+ } else if (character == ')') {
+ depth--;
+ if (depth == 0 && index < text.length() - 1) {
+ return text;
+ }
+ }
+ }
+
+ if (depth != 0) {
+ return text;
+ }
+
+ return text.substring(1, text.length() - 1);
+ }
+
+ /**
+ * Recursive descent parser for the LDAP filter grammar of RFC 1960, rendering the result in infix notation.
+ * <p>
+ * The grammar is small. A filter is parenthesised and holds either a composite - "&", "|" or "!" followed by nested filters - or a
+ * single item of the form attribute, operator, value. Rendering it infix means the operator ends up between its operands, and the
+ * parentheses that only carried the prefix grouping disappear.
+ * <p>
+ * Every method returns null on anything the grammar does not allow, which the caller turns into the untouched filter. Nothing here ever
+ * throws, because it runs while another failure is already being reported.
+ */
+ private static final class FilterParser {
+
+ /**
+ * A parsed subexpression. Composite subexpressions are the ones that need their grouping shown when they are nested inside another
+ * operator; a single item never does.
+ */
+ private record Expression(String text, boolean composite) {
+
+ String grouped() {
+ return composite ? "(" + text + ")" : text;
+ }
+ }
+
+ private final String filter;
+
+ private int position;
+
+ FilterParser(String filter) {
+ this.filter = filter;
+ }
+
+ /**
+ * @return The whole filter in infix notation, or null when it is not a filter this parser understands.
+ */
+ String parse() {
+ if (filter == null || filter.isEmpty()) {
+ return null;
+ }
+
+ Expression expression = parseFilter();
+ if (expression == null) {
+ return null;
+ }
+
+ skipWhitespace();
+ if (position != filter.length()) {
+ return null;
+ }
+
+ return expression.text();
+ }
+
+ private Expression parseFilter() {
+ skipWhitespace();
+ if (position >= filter.length() || filter.charAt(position) != '(') {
+ return null;
+ }
+ position++;
+
+ skipWhitespace();
+ if (position >= filter.length()) {
+ return null;
+ }
+
+ Expression expression = switch (filter.charAt(position)) {
+ case '&', '|' -> parseComposite(filter.charAt(position));
+ case '!' -> parseNegation();
+ default -> parseItem();
+ };
+
+ if (expression == null) {
+ return null;
+ }
+
+ skipWhitespace();
+ if (position >= filter.length() || filter.charAt(position) != ')') {
+ return null;
+ }
+ position++;
+
+ return expression;
+ }
+
+ private Expression parseComposite(char operator) {
+ position++;
+
+ StringBuilder expressionBuilder = new StringBuilder(64);
+ int operands = 0;
+
+ skipWhitespace();
+ while (position < filter.length() && filter.charAt(position) == '(') {
+ Expression operand = parseFilter();
+ if (operand == null) {
+ return null;
+ }
+
+ if (operands > 0) {
+ expressionBuilder.append(' ').append(operator).append(' ');
+ }
+ expressionBuilder.append(operand.grouped());
+ operands++;
+
+ skipWhitespace();
+ }
+
+ if (operands == 0) {
+ return null;
+ }
+
+ // A single operand needs no grouping of its own, whatever the operator in front of it was.
+ return new Expression(expressionBuilder.toString(), operands > 1);
+ }
+
+ private Expression parseNegation() {
+ position++;
+
+ Expression operand = parseFilter();
+ if (operand == null) {
+ return null;
+ }
+
+ return new Expression("!(" + operand.text() + ")", false);
+ }
+
+ private Expression parseItem() {
+ int attributeStart = position;
+ while (position < filter.length() && isAttributeCharacter(filter.charAt(position))) {
+ position++;
+ }
+
+ String attribute = filter.substring(attributeStart, position).trim();
+ if (attribute.isEmpty()) {
+ return null;
+ }
+
+ String operator = readOperator();
+ if (operator == null) {
+ return null;
+ }
+
+ return new Expression(shortenNamespace(attribute) + " " + operator + " " + readValue(), false);
+ }
+
+ private String readOperator() {
+ if (position >= filter.length()) {
+ return null;
+ }
+
+ char character = filter.charAt(position);
+ if (character == '=') {
+ position++;
+ return "=";
+ }
+
+ if (position + 1 >= filter.length() || filter.charAt(position + 1) != '=') {
+ return null;
+ }
+
+ position += 2;
+
+ return character + "=";
+ }
+
+ /**
+ * Reads a value up to the closing parenthesis of its item. A parenthesis inside a value has to be escaped, so a backslash always
+ * takes the character behind it along.
+ */
+ private String readValue() {
+ StringBuilder valueBuilder = new StringBuilder(32);
+
+ while (position < filter.length() && filter.charAt(position) != ')') {
+ char character = filter.charAt(position);
+
+ if (character == '\\' && position + 1 < filter.length()) {
+ valueBuilder.append(character).append(filter.charAt(position + 1));
+ position += 2;
+ continue;
+ }
+
+ valueBuilder.append(character);
+ position++;
+ }
+
+ return valueBuilder.toString().trim();
+ }
+
+ private boolean isAttributeCharacter(char character) {
+ return character != '=' && character != '>' && character != '<' && character != '~'
+ && character != '(' && character != ')';
+ }
+
+ private void skipWhitespace() {
+ while (position < filter.length() && Character.isWhitespace(filter.charAt(position))) {
+ position++;
+ }
+ }
+ }
+
+ private static void printLine(StringBuilder messageBuilder, int indent, String text) {
+ if (text == null || text.isBlank()) {
+ return;
+ }
+
+ messageBuilder.append(" ".repeat(indent * INDENT_WIDTH)).append(text.trim()).append('\n');
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Bundle information
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Reports, for every distinct package that could not be wired, which bundles export it, at which version, and whether that export
+ * actually satisfies the requirement. The package name and the filter come from the parse, not from re-reading our own output.
+ *
+ * @return The ids of the exporting bundles, so the caller can describe them once.
+ */
+ private static Set<Long> appendExporters(BundleContext bundleContext, List<ResolutionFailure> failures, StringBuilder messageBuilder) {
+ Set<Long> bundleIds = new LinkedHashSet<>();
+ Set<String> reportedPackages = new LinkedHashSet<>();
+
+ for (ResolutionFailure failure : failures) {
+ if (!PACKAGE_NAMESPACE.equals(failure.namespace()) || failure.filter() == null) {
continue;
}
- List<Capability> caps = rev.getCapabilities(PACKAGE_NAMESPACE);
- for (Capability cap : caps) {
- Map<String, Object> attrs = cap.getAttributes();
- Object exportedPkg = attrs.get(PACKAGE_NAMESPACE);
+ String packageName = findRequiredPackage(failure.filter());
+ if (packageName == null || !reportedPackages.add(packageName)) {
+ continue;
+ }
- if (packageName.equals(exportedPkg)) {
- exporters.add(b);
- break; // one match is enough per bundle
- }
+ List<PackageExport> exports = findExporters(bundleContext, packageName, createFilter(failure.filter()));
+ if (exports.isEmpty()) {
+ messageBuilder.append("\nNo bundle exports \"").append(packageName).append("\"\n");
+ continue;
+ }
+
+ messageBuilder.append("\nThe following bundles export \"").append(packageName).append("\"\n");
+ for (PackageExport export : exports) {
+ bundleIds.add(export.bundle().getBundleId());
+ appendExport(export, packageName, messageBuilder);
}
}
- return exporters;
+ return bundleIds;
}
- private static void tryAddPomProperties(Bundle bundle, StringBuilder bundleBuilder) {
+ private static void appendExport(PackageExport export, String packageName, StringBuilder messageBuilder) {
+ messageBuilder.append(" ")
+ .append(export.match().label())
+ .append(' ')
+ .append(export.bundle().getSymbolicName())
+ .append(' ')
+ .append(export.bundle().getVersion())
+ .append(" [")
+ .append(export.bundle().getBundleId())
+ .append("] ")
+ .append(toStateName(export.bundle()))
+ .append(" - exports ")
+ .append(packageName)
+ // The bundle version above and the exported package version below are routinely different.
+ .append(" at version ")
+ .append(export.packageVersion())
+ .append('\n');
+ }
+
+ private static void appendBundleInfo(Bundle bundle, StringBuilder messageBuilder) {
+ messageBuilder.append('[').append(bundle.getBundleId()).append("]\n");
+ messageBuilder.append("jar = ").append(bundle.getLocation());
+ appendPomProperties(bundle, messageBuilder);
+ messageBuilder.append('\n');
+ }
+
+ private static void appendPomProperties(Bundle bundle, StringBuilder messageBuilder) {
+ // Note: findEntries is specified to attempt to resolve the bundle so that fragment entries can be searched. We are calling it while
+ // reporting a resolution failure, which is why it is the last thing we do and why every failure below is swallowed.
Enumeration<URL> entries = bundle.findEntries("META-INF/maven/", "pom.properties", true);
if (entries == null) {
return;
}
- while (entries.hasMoreElements()) {
- try (BufferedReader reader = new BufferedReader(new InputStreamReader(entries.nextElement().openStream(), UTF_8))) {
+ int printed = 0;
+ while (entries.hasMoreElements() && printed < MAX_POM_PROPERTIES_PER_BUNDLE) {
+ URL entry = entries.nextElement();
+ printed++;
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(entry.openStream(), UTF_8))) {
reader.lines()
- .filter(e -> !e.startsWith("#"))
- .forEach(e -> bundleBuilder.append('\n').append(e.replace("=", " = ")));
- } catch (IOException e1) {
- // Ignore
+ .map(String::trim)
+ .filter(line -> !line.isEmpty() && !line.startsWith("#"))
+ .forEach(line -> messageBuilder.append('\n').append(line.replace("=", " = ")));
+ } catch (IOException e) {
+ // The content is unreadable, the location printed above is all we can offer.
}
- bundleBuilder.append('\n');
+ }
+
+ if (entries.hasMoreElements()) {
+ messageBuilder.append("\n... further Maven coordinates omitted");
}
}
+ // -----------------------------------------------------------------------------------------------------------------------------------
+ // Exporter lookup
+ // -----------------------------------------------------------------------------------------------------------------------------------
+
+ private enum RequirementMatch {
+
+ MATCHES("[matches] "),
+ MISMATCH("[mismatch]"),
+ UNKNOWN("[unknown] ");
+
+ private final String label;
+
+ RequirementMatch(String label) {
+ this.label = label;
+ }
+
+ String label() {
+ return label;
+ }
+ }
+
+ private record PackageExport(Bundle bundle, Version packageVersion, RequirementMatch match) {
+ }
/**
- * @param message - error message from the exception
- * @return list of bundle ids (are in square brackets in the message)
+ * Finds every bundle that declares an export of the package, resolved or not, and lets the framework itself decide whether that export
+ * satisfies the requirement. Declared capabilities are used on purpose: a bundle that cannot be resolved is exactly the one you want to
+ * see in this list.
*/
- public static List<Long> findBundleIds(final String message) {
- if (message == null || message.isEmpty()) {
- return Collections.emptyList();
+ private static List<PackageExport> findExporters(BundleContext bundleContext, String packageName, Filter requirementFilter) {
+ List<PackageExport> exports = new ArrayList<>();
+
+ for (Bundle bundle : bundleContext.getBundles()) {
+ BundleRevision revision = bundle.adapt(BundleRevision.class);
+ if (revision == null) {
+ continue;
+ }
+
+ for (Capability capability : revision.getCapabilities(PACKAGE_NAMESPACE)) {
+ Map<String, Object> attributes = capability.getAttributes();
+ if (!packageName.equals(attributes.get(PACKAGE_NAMESPACE))) {
+ continue;
+ }
+
+ exports.add(new PackageExport(bundle, toVersion(attributes.get(CAPABILITY_VERSION_ATTRIBUTE)),
+ toMatch(requirementFilter, attributes)));
+ break;
+ }
}
- Set<Long> bundleIds = new LinkedHashSet<>();
- Matcher bundlePattern = BUNDLE_PATTERN.matcher(message);
- while (bundlePattern.find()) {
- String number = bundlePattern.group(1);
- bundleIds.add(Long.valueOf(number));
- }
- return new ArrayList<>(bundleIds);
+
+ return exports;
}
-
- private static void printLn(StringBuilder messageBuilder, int indent, String message) {
- for (int i = 0; i < (indent * 4); i++) {
- messageBuilder.append(" ");
+ private static RequirementMatch toMatch(Filter requirementFilter, Map<String, Object> attributes) {
+ if (requirementFilter == null) {
+ return RequirementMatch.UNKNOWN;
}
- messageBuilder.append(message.trim()).append('\n');
+
+ return requirementFilter.matches(attributes) ? RequirementMatch.MATCHES : RequirementMatch.MISMATCH;
}
-}
+
+ private static Filter createFilter(String filter) {
+ try {
+ return FrameworkUtil.createFilter(filter);
+ } catch (InvalidSyntaxException e) {
+ // We did not understand the filter the way the framework does - report the exporters without a verdict.
+ return null;
+ }
+ }
+
+ /**
+ * Reads the required package name out of the filter. This is bounded to the filter, which the parser extracted by balancing
+ * parentheses, so it can no longer run past the end of a line and swallow the rest of the message.
+ */
+ private static String findRequiredPackage(String filter) {
+ String assertion = PACKAGE_NAMESPACE + "=";
+
+ int index = filter.indexOf(assertion);
+ if (index < 0) {
+ return null;
+ }
+
+ int start = index + assertion.length();
+ int end = start;
+ while (end < filter.length() && filter.charAt(end) != '(' && filter.charAt(end) != ')') {
+ end++;
+ }
+
+ String packageName = filter.substring(start, end).trim();
+
+ return packageName.isEmpty() ? null : packageName;
+ }
+
+ private static Version toVersion(Object value) {
+ if (value instanceof Version version) {
+ return version;
+ }
+
+ if (value == null) {
+ return Version.emptyVersion;
+ }
+
+ try {
+ return Version.parseVersion(value.toString());
+ } catch (IllegalArgumentException e) {
+ return Version.emptyVersion;
+ }
+ }
+
+ private static String toStateName(Bundle bundle) {
+ return switch (bundle.getState()) {
+ case Bundle.UNINSTALLED -> "UNINSTALLED";
+ case Bundle.INSTALLED -> "INSTALLED";
+ case Bundle.RESOLVED -> "RESOLVED";
+ case Bundle.STARTING -> "STARTING";
+ case Bundle.STOPPING -> "STOPPING";
+ case Bundle.ACTIVE -> "ACTIVE";
+ default -> "UNKNOWN";
+ };
+ }
+}
\ No newline at end of file
diff --git a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java
index d88addc..7e19785 100755
--- a/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java
+++ b/osgi/adapter/src/main/java/org/jvnet/hk2/osgiadapter/OSGiModuleImpl.java
@@ -182,7 +182,15 @@
}
} catch (BundleException e) {
throw new ResolveError(
- "Failed to start " + this + prettyPrintFelixMessage(registry.getBundleContext(), e.getMessage()),
+ "Failed to start " + this +
+ prettyPrintFelixMessage(registry.getBundleContext(), e.getMessage()) +
+ "\n\n" +
+ BundleResolutionAnalyzer.explainUnresolvedBundles(registry.getBundleContext()) +
+ "\n\n" +
+ BundleResolutionAnalyzer.explain(registry.getBundleContext(), bundle) +
+ "\n\n" +
+ BundleResolutionAnalyzer.findRootCauses(registry.getBundleContext(), bundle)
+ ,
e);
}
diff --git a/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java b/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java
index 801b011..768a522 100644
--- a/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java
+++ b/osgi/adapter/src/test/java/org/jvnet/hk2/osgiadapter/FelixPrettyPrinterTest.java
@@ -16,21 +16,34 @@
package org.jvnet.hk2.osgiadapter;
-import java.util.List;
-
import org.junit.Test;
+import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.stringContainsInOrder;
+import static org.jvnet.hk2.osgiadapter.FelixPrettyPrinter.findBundleIds;
import static org.jvnet.hk2.osgiadapter.FelixPrettyPrinter.prettyPrintExceptionMessage;
+/**
+ * Expected values are written as text blocks, because the indentation is the thing under test and concatenated literals hide it.
+ */
public class FelixPrettyPrinterTest {
+ private static final String FELIX_SCR_MESSAGE = " Unable to resolve"
+ + " org.apache.felix.scr [304](R 304.0):"
+ + " missing requirement [org.apache.felix.scr [304](R 304.0)] osgi.wiring.package;"
+ + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))"
+ + " Unresolved requirements: [[org.apache.felix.scr [304](R304.0)] osgi.wiring.package;"
+ + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))]\n"
+ + "at org.apache.felix.framework.Felix.resolveBundleRevision(Felix.java:4398) ";
+
@Test
public void testFormatting() {
- String src = "org.osgi.framework.BundleException:"
+ String source = "org.osgi.framework.BundleException:"
+ " Unable to resolve org.glassfish.main.webservices.connector [207](R 207.0):"
+ " missing requirement [org.glassfish.main.webservices.connector [207](R 207.0)] osgi.wiring.package;"
+ " (&(osgi.wiring.package=jakarta.xml.ws)(version>=3.0.0)(!(version>=4.0.0))) [caused by:"
@@ -39,27 +52,28 @@
+ " (&(osgi.wiring.package=jakarta.xml.bind)(version>=3.0.0)(!(version>=4.0.0)))]"
+ " Unresolved requirements: [[org.glassfish.main.webservices.connector [207](R 207.0)] osgi.wiring.package;"
+ " (&(osgi.wiring.package=jakarta.xml.ws)(version>=3.0.0)(!(version>=4.0.0)))]";
- String message = FelixPrettyPrinter.prettyPrintExceptionMessage(src);
- assertThat(message,
- equalTo(
- "Unable to resolve\n"
- + " org.glassfish.main.webservices.connector [207]\n"
- + " missing requirement\n"
- + " &(package = jakarta.xml.ws) (version >= 3.0.0) (!(version >= 4.0.0))\n"
- + " caused by:\n"
- + " Unable to resolve\n"
- + " org.glassfish.metro.webservices-api-osgi [236]\n"
- + " missing requirement\n"
- + " &(package = jakarta.xml.bind) (version >= 3.0.0) (!(version >= 4.0.0)))]\n"));
- assertThat(FelixPrettyPrinter.findBundleIds(message), contains(207L, 236L));
- assertThat(FelixPrettyPrinter.findBundleIds(src), contains(207L, 236L));
+ String message = prettyPrintExceptionMessage(source);
+ assertThat(message, equalTo("""
+ org.osgi.framework.BundleException:
+ Unable to resolve
+ org.glassfish.main.webservices.connector [207]
+ missing requirement
+ package = jakarta.xml.ws & version >= 3.0.0 & !(version >= 4.0.0)
+ caused by:
+ Unable to resolve
+ org.glassfish.metro.webservices-api-osgi [236]
+ missing requirement
+ package = jakarta.xml.bind & version >= 3.0.0 & !(version >= 4.0.0)
+ """));
+
+ assertThat(findBundleIds(message), contains(207L, 236L));
+ assertThat(findBundleIds(source), contains(207L, 236L));
}
@Test
public void testWeld() {
- String text = prettyPrintExceptionMessage(
- "org.osgi.framework.BundleException:"
+ String message = prettyPrintExceptionMessage("org.osgi.framework.BundleException:"
+ " Unable to resolve org.glassfish.main.web.weld-integration [41](R 41.0):"
+ " missing requirement [org.glassfish.main.web.weld-integration [41](R 41.0)] osgi.wiring.package;"
+ " (&(osgi.wiring.package=jakarta.faces.application)(version>=4.1.0)(!(version>=5.0.0)))"
@@ -69,27 +83,135 @@
+ " Unresolved requirements: [[org.glassfish.main.web.weld-integration [41](R 41.0)]"
+ " osgi.wiring.package;"
+ " (&(osgi.wiring.package=jakarta.faces.application)(version>=4.1.0)(!(version>=5.0.0)))]");
- assertThat(text,
+
+ assertThat(message,
stringContainsInOrder("Unable to resolve", "org.glassfish.main.web.weld-integration", "missing requirement",
"jakarta.faces.application", "caused by:", "Unable to resolve", "org.glassfish.jakarta.faces",
- "missing requirement", "jakarta.enterprise.inject", "(version >= 4.1.0) (!(version >= 5.0.0))"));
- assertThat(FelixPrettyPrinter.findBundleIds(text), contains(41L, 291L));
+ "missing requirement", "jakarta.enterprise.inject", "version >= 4.1.0 & !(version >= 5.0.0)"));
+ assertThat(findBundleIds(message), contains(41L, 291L));
}
@Test
public void testFelix() {
- String src = FelixPrettyPrinter.prettyPrintExceptionMessage(" Unable to resolve"
- + " org.apache.felix.scr [304](R 304.0):"
- + " missing requirement [org.apache.felix.scr [304](R 304.0)] osgi.wiring.package;"
- + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))"
- + " Unresolved requirements: [[org.apache.felix.scr [304](R304.0)] osgi.wiring.package;"
- + " (&(osgi.wiring.package=org.osgi.framework)(version>=1.10.0)(!(version>=2.0.0)))]\n"
- + "at org.apache.felix.framework.Felix.resolveBundleRevision(Felix.java:4398) ");
- String message = FelixPrettyPrinter.prettyPrintExceptionMessage(src);
- assertThat(message,
- stringContainsInOrder("Unable to resolve\n", "org.apache.felix.scr [304]\n", "missing requirement\n"));
+ String message = prettyPrintExceptionMessage(FELIX_SCR_MESSAGE);
+ assertThat(message, equalTo("""
+ Unable to resolve
+ org.apache.felix.scr [304]
+ missing requirement
+ package = org.osgi.framework & version >= 1.10.0 & !(version >= 2.0.0)
+ """));
- List<Long> ids = FelixPrettyPrinter.findBundleIds(message);
- assertThat(ids, contains(304L));
+ assertThat(findBundleIds(message), contains(304L));
}
-}
+
+ /**
+ * A requirement on an execution environment used to be reported under the wrong module, because the parser searched forward for a
+ * package requirement without staying inside the clause it was describing.
+ */
+ @Test
+ public void testExecutionEnvironment() {
+ String message = prettyPrintExceptionMessage("org.osgi.framework.BundleException:"
+ + " Unable to resolve org.glassfish.main.core [12](R 12.0):"
+ + " missing requirement [org.glassfish.main.core [12](R 12.0)] osgi.ee;"
+ + " (&(osgi.ee=JavaSE)(version=21))");
+
+ assertThat(message, equalTo("""
+ org.osgi.framework.BundleException:
+ Unable to resolve
+ org.glassfish.main.core [12]
+ missing requirement
+ osgi.ee = JavaSE & version = 21
+ """));
+ }
+
+ @Test
+ public void testFragmentHost() {
+ String message = prettyPrintExceptionMessage("Unable to resolve org.hibernate.validator.cdi [263](R 263.0):"
+ + " missing requirement [org.hibernate.validator.cdi [263](R 263.0)] osgi.wiring.host;"
+ + " (&(osgi.wiring.host=org.hibernate.validator)(bundle-version>=9.1.0))");
+
+ assertThat(message, equalTo("""
+ Unable to resolve
+ org.hibernate.validator.cdi [263]
+ missing requirement
+ host = org.hibernate.validator & bundle-version >= 9.1.0
+ """));
+ }
+
+ @Test
+ public void testRequirementWithoutVersionRange() {
+ String message = prettyPrintExceptionMessage("Unable to resolve org.glassfish.main.foo [7](R 7.0):"
+ + " missing requirement [org.glassfish.main.foo [7](R 7.0)] osgi.wiring.package;"
+ + " (osgi.wiring.package=jakarta.validation)");
+
+ assertThat(message, equalTo("""
+ Unable to resolve
+ org.glassfish.main.foo [7]
+ missing requirement
+ package = jakarta.validation
+ """));
+ }
+
+ /**
+ * Nesting inside a filter keeps its parentheses, so that the grouping the resolver meant is still visible once the operators move
+ * between their operands.
+ */
+ @Test
+ public void testNestedDisjunctionKeepsItsGrouping() {
+ String message = prettyPrintExceptionMessage("Unable to resolve org.glassfish.main.foo [7](R 7.0):"
+ + " missing requirement [org.glassfish.main.foo [7](R 7.0)] osgi.wiring.package;"
+ + " (&(osgi.wiring.package=org.bar)(|(version=1.0.0)(version=2.0.0)))");
+
+ assertThat(message, containsString("package = org.bar & (version = 1.0.0 | version = 2.0.0)"));
+ }
+
+ /**
+ * A message the parser does not fully understand must never be replaced by a shorter one that drops what it could not read.
+ */
+ @Test
+ public void testUnrecognisedMessageKeepsTheOriginal() {
+ String source = "Unable to resolve org.glassfish.main.foo [7](R 7.0): something the resolver has never said before";
+
+ assertThat(prettyPrintExceptionMessage(source), containsString(source));
+ }
+
+ /**
+ * Formatting an already formatted message is a mistake, but it still may not lose anything.
+ */
+ @Test
+ public void testAlreadyFormattedMessageIsNotDestroyed() {
+ String once = prettyPrintExceptionMessage(FELIX_SCR_MESSAGE);
+
+ assertThat(prettyPrintExceptionMessage(once), containsString(once));
+ }
+
+ /**
+ * Two failures are only nested when the resolver said one caused the other. Without a "caused by" they are siblings.
+ */
+ @Test
+ public void testSiblingFailuresAreNotNested() {
+ String message = prettyPrintExceptionMessage("Unable to resolve A [1](R 1.0):"
+ + " missing requirement [A [1](R 1.0)] osgi.wiring.package; (osgi.wiring.package=p.one)"
+ + " Unable to resolve B [2](R 2.0):"
+ + " missing requirement [B [2](R 2.0)] osgi.wiring.package; (osgi.wiring.package=p.two)");
+
+ assertThat(message, equalTo("""
+ Unable to resolve
+ A [1]
+ missing requirement
+ package = p.one
+ Unable to resolve
+ B [2]
+ missing requirement
+ package = p.two
+ """));
+ }
+
+ @Test
+ public void testNullAndEmptyMessage() {
+ assertThat(prettyPrintExceptionMessage(null), nullValue());
+ assertThat(prettyPrintExceptionMessage(""), equalTo(""));
+ assertThat(findBundleIds(null), empty());
+ assertThat(findBundleIds(""), empty());
+ }
+}
\ No newline at end of file