Skip to content

FELIX-6759 - Remove sun.misc.Unsafe - use plurl for URL handler factory multiplexing - #552

Open
paulrutter wants to merge 3 commits into
feature/FELIX-6759-Java-25-LTSfrom
feature/FELIX-6759-plurl
Open

FELIX-6759 - Remove sun.misc.Unsafe - use plurl for URL handler factory multiplexing#552
paulrutter wants to merge 3 commits into
feature/FELIX-6759-Java-25-LTSfrom
feature/FELIX-6759-plurl

Conversation

@paulrutter

@paulrutter paulrutter commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes the sun.misc.Unsafe warning reported in FELIX-6759 by using plurl to multiplex the JVM URL factory singletons, instead of the framework claiming them by reflectively clearing private static fields of java.net.URL.

Stacked on #433 (FELIX-6807, Java 25 LTS support) and targets that branch, so it merges after it.

Why

URLHandlers installed itself as the JVM stream and content handler factory by clearing java.net.URL's private static fields. Those are not accessible under JPMS, so SecureAction obtains a trusted MethodHandles.Lookup by reading MethodHandles.Lookup.IMPL_LOOKUP through Unsafe.staticFieldOffset, and uses it to define an accessor class inside java.base. That staticFieldOffset call is the one in the report. Two consequences beyond the warning: the last framework to install itself wins the singleton, and the takeover leaves the JVM factories occupied before any cooperative multiplexer can take them.

The mechanism has already been patched twice, in FELIX-6430 and FELIX-6432. This stops needing it rather than adapting it again.

What plurl changes

plurl installs one cooperative router through the supported URL.setURLStreamHandlerFactory API and routes between registered factories, asking each whether a calling class, or the URL being parsed, belongs to it. Each framework instance registers its own factory. Equinox uses the same mechanism, so a JVM hosting both can share one router.

Deliberately no fallback to the field-swapping path: if registration fails the framework contributes no URL handlers, matching Equinox. Keeping the fallback would defeat the purpose.

Upstream

plurl needed a change before it could route bundle: URLs: every framework instance uses the same bundle: protocol, so the protocol does not identify an owner, and a URL parsed by a caller in no bundle leaves nothing on the call stack either — plurl selected the first factory added, which then cannot resolve another instance's UUID.

That is merged upstream as eclipse-osgi-technology/plurl#63, with the issue at eclipse-osgi-technology/plurl#62. Thanks to @tjwatson for the review, which found a gap in the first cut: the JVM asks for a handler once per protocol with no URL, so a protocol served only by a factory that claims it by URL was declined before per-URL selection ever ran.

The sources are still vendored under org.apache.felix.framework.plurl, byte for byte as upstream except the package rename, because plurl has not been released to Maven Central yet. Once it is, this becomes a provided dependency embedded with Private-Package, the way org.apache.felix.resolver already is, and the vendored files go away. The package is private, so that swap is invisible to anyone outside the bundle. Apache-2.0, credited in the framework NOTICE alongside the OSGi Alliance entry.

Effect on the reported warning

Starting a framework on JDK 25 no longer produces it: measured across JDK 17/21/23/25, no terminal deprecation warning is attributed to Felix for a plain framework start, and the OSGi Core R8 TCK run is clean.

This is not a complete elimination of Unsafe. One call site remains, reached only when a bundle registers a URLStreamHandlerService: URLHandlersStreamHandlerProxy reflects the protected methods of java.net.URLStreamHandler to delegate to a JDK built-in handler. Protected access does not extend to invoking those methods on a different handler instance, so subclassing does not help; delegating to a built-in handler requires the package to be open. plurl has the same requirement and its own build passes --add-opens java.base/java.net.

SecureAction.getAccessor is therefore kept, but is now reached only when ordinary reflection has already been refused. The supported way to satisfy it is for the launcher to grant access: --add-opens java.base/java.net and, for extension bundles, --add-opens java.base/jdk.internal.loader. org.apache.felix.main already declares both in its Add-opens manifest header, honoured because it is launched with java -jar. Embedders that put the framework on a classpath must pass the flags themselves — a manifest header does nothing there.

Measured under deny

On JDK 25 with --sun-misc-unsafe-memory-access=deny, which is what a future JDK will default to before the methods are removed, the framework starts and 119 of 122 framework tests pass. The three failures are all framework extension bundles, failing cleanly with BundleException: Could not create bundle object. Nothing corrupts or hangs.

Verification

  • URLHandlersTest.urlHandlersWithClassLoaderIsolation passes: a caller outside any bundle still resolves a bundle: URL to the framework that owns it.
  • PlurlURLHandlersTest covers the plurl registration, that only this framework's classes and bundle: URLs are claimed, another framework's UUID is not, and that the installed plurl reports selection by URL — so re-vendoring an older plurl fails there rather than quietly turning the startup warning on for everyone.
  • Framework suite green on JDK 17/21/23/25 in CI, and the OSGi Core R8 TCK passes on JDK 25.

🤖 Generated with Claude Code

paulrutter added a commit that referenced this pull request Aug 29, 2026
The pull_request trigger filters on the BASE branch, and only accepted master. A
stacked pull request - one opened against another feature branch rather than master -
therefore matched neither trigger and got no CI at all, which is what happened to
PR #552.

Accept feature/** and maintenance/** as a base as well, so stacked work is built
before it is merged down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@paulrutter

Copy link
Copy Markdown
Contributor Author

CI is red on purpose — Apache RAT rejects the vendored sources

Now that stacked PRs get CI (the pull_request trigger only accepted master as a base, so this PR was getting no build at all until that was fixed on the base branch), the build runs — and fails on all four JDKs in exactly the way that matters:

[INFO] Rat check: Summary over all files. Unapproved: 12, unknown: 12, generated: 0, approved: 243 licenses.
[ERROR] Failed to execute goal org.apache.rat:apache-rat-plugin:0.15:check (default)
        on project org.apache.felix.framework: Too many files with unapproved license: 12

All 12 flagged files are the vendored plurl sources — the 11 .java files plus the README.md. Nothing else in the framework is flagged.

This is the licensing question answering itself. apache-rat-plugin is the ASF's own compliance gate, and it will not accept SPDX-License-Identifier: EPL-2.0 headers in an Apache source tree. It is enforcing the Category B rule automatically. Equinox could vendor these files because Eclipse projects are EPL-2.0 natively; Felix cannot.

The failure is being left in place deliberately. It could be silenced with a RAT exclusion for plurl/**, but that would suppress a genuine compliance violation rather than resolve it, and would leave a green tick implying the licensing was settled when it is not. Red is the accurate state: this branch cannot merge as it stands.

So the technical side is done and the remaining blocker is purely licensing:

  • the framework compiles and its tests are unchanged with plurl vendored and PlurlURLHandlers wired in;
  • the only thing standing in the way is the header discrepancy — plurl's LICENSE and pom.xml say Apache-2.0, while every source file says EPL-2.0 with Copyright (c) 2025 IBM Corporation.

@tjwatson — if the EPL headers are carried over from the original Equinox code rather than intended, correcting them upstream to match plurl's own Apache-2.0 LICENSE and pom would turn this red build green and unblock the approach for Felix and anyone else wanting to adopt the library. If they are intentionally EPL-2.0, then Felix needs one of the alternatives listed in the PR description instead.

🤖 Generated with Claude Code

@tjwatson

Copy link
Copy Markdown
Member

The intention when I moved the source to osgi-technology was that I would use the apache header like this:

https://github.com/eclipse-osgi-technology/osgi-test/blob/696b03cd2eaf4e91f7ad0e3d424e4bebb28525ce/org.osgi.test.assertj.feature/src/test/java/org/osgi/test/assertj/feature/AssertionsTest.java#L1-L17

Somehow that was overlooked when setting up the source in https://github.com/eclipse-osgi-technology/plurl

It should be a non-issue to get the headers updated. But what I ultimately wanted to do was have a release out of the osgi-technology project for plurl that would be used by both Equinox and Felix unchanged, in the original package there.

@paulrutter

Copy link
Copy Markdown
Contributor Author

The intention when I moved the source to osgi-technology was that I would use the apache header like this:

https://github.com/eclipse-osgi-technology/osgi-test/blob/696b03cd2eaf4e91f7ad0e3d424e4bebb28525ce/org.osgi.test.assertj.feature/src/test/java/org/osgi/test/assertj/feature/AssertionsTest.java#L1-L17

Somehow that was overlooked when setting up the source in https://github.com/eclipse-osgi-technology/plurl

It should be a non-issue to get the headers updated. But what I ultimately wanted to do was have a release out of the osgi-technology project for plurl that would be used by both Equinox and Felix unchanged, in the original package there.

Thanks for your quick reply! Let's first see if we can get consensus on the JDK 25 PR, then we can revisit this one.

Would it be possible to get plurl released as a separate artifact, with the right headers, so we can use it without copying the code into Felix?

Apart from that, does this PR make sense to you?

@tjwatson

tjwatson commented Sep 1, 2026

Copy link
Copy Markdown
Member

Would it be possible to get plurl released as a separate artifact, with the right headers, so we can use it without copying the code into Felix?

I've updated the source headers to use Apache-2.0 with: eclipse-osgi-technology/plurl#45

Note that the source also needed an upstream fix I had put in Equinox with PR: eclipse-osgi-technology/plurl#55

I am not sure how long it will take to get a release out of the osgi-technology project because I have not done a release out of this particular project yet. I'll ask existing members of the project about what steps are needed to get a release done. But I don't want that to block you on progress. With the current state of the source I think it should be fine for you to proceed with copying in the source now.

@paulrutter

paulrutter commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that's great! I will pull in the latest version and use it verbatim except the package names. Once the JDK 25 PR lands, this one is next on the list.

Once a proper release is out, we can then switch to that artifact instead and remove the vendored sources.

@tjwatson

tjwatson commented Sep 1, 2026

Copy link
Copy Markdown
Member

I don't see where you are calling org.eclipse.osgitech.plurl.Plurl.install(String...) to get the singleton factories registered with the JVM.

Comment on lines +108 to +110
return name.startsWith("org.apache.felix.framework.BundleWiringImpl$BundleClassLoader")
|| name.startsWith("org.apache.felix.framework.ModuleImpl$ModuleClassLoader")
|| name.equals("org.apache.felix.framework.searchpolicy.ContentClassLoader");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Equinox I do an additional check to make sure the class loader is for a bundle that is actually installed in this framework instance. I assume you would want the same thing here. Otherwise one felix framework may handle lookups for a bundle resolved in another felix framework instance.

@paulrutter

Copy link
Copy Markdown
Contributor Author

I don't see where you are calling org.eclipse.osgitech.plurl.Plurl.install(String...) to get the singleton factories registered with the JVM.

Looking into this, i stopped working on the draft when i noticed the license. Will continue with it now that's resolved 👍🏻

@tjwatson

tjwatson commented Sep 1, 2026

Copy link
Copy Markdown
Member

I don't see where you are calling org.eclipse.osgitech.plurl.Plurl.install(String...) to get the singleton factories registered with the JVM.

Looking into this, i stopped working on the draft when i noticed the license. Will continue with it now that's resolved 👍🏻

I should mention that the intention was to use a ServiceLoader call to lookup the implementation instance of Plurl. But I found just doing a new PlurlImpl() is easier as long as you don't care to replace the impl with some other META-INF/services implementation.

paulrutter added a commit that referenced this pull request Sep 1, 2026
Addresses the review on PR #552 and makes plurl the mechanism rather than an
illustration alongside the existing one.

Vendored sources refreshed from upstream. Every file is now Apache-2.0
(eclipse-osgi-technology/plurl#45 corrected headers that were EPL-2.0 by oversight,
which apache-rat rightly rejected) and includes the fix from
eclipse-osgi-technology/plurl#55. Only the package rename is applied.

The prototype could not have worked as written:

- Plurl.install(..) was never called. The static Plurl.add(..) helpers operate through
  a plurl: URL, so without installing the router first they fail with "unknown
  protocol: plurl". Nothing exercised the adapter, so it compiled and the tests passed
  while being dead code.
- shouldHandle only checked that a class came from some Felix bundle class loader. Two
  frameworks in one JVM both load classes that way, so one framework could answer
  lookups for a bundle resolved in another. It now also requires the owning framework
  to match, as Equinox does.

Plurl is now the only mechanism, matching Equinox rather than keeping a fallback:

- URLHandlers no longer swaps the java.net.URL and URLConnection static fields, and
  its singleton is no longer constructed. Keeping that as a fallback would have kept
  the sun.misc.Unsafe path alive, which is what adopting plurl is meant to remove.
  Equinox does not fall back either: if plurl registration fails it logs and that
  framework contributes no URL handlers.
- Removing the swap turned out to be necessary rather than merely tidy. With
  URLHandlers taking the singletons first, plurl found them occupied and required
  --add-opens java.base/java.net to replace them. Installing into a clean JVM uses the
  supported API and needs no flag.
- URLHandlersBundleStreamHandler and URLHandlersStreamHandlerProxy now extend
  PlurlStreamHandlerBase, exactly as Equinox changed BundleResourceHandler and
  URLStreamHandlerProxy. Otherwise plurl has to reflect into java.net to drive them.
- The router is installed once per JVM and reference counted. Uninstalling it per
  framework would tear it down while another framework was still using it.
- The bundle: handler is deliberately not bound to one framework. The JVM caches one
  handler per protocol, so a pinned handler is also used for another framework's
  bundle: URLs; the framework is resolved per call from the UUID in the URL host.
- URLHandlers.m_sm is initialised eagerly, since the constructor that used to set it up
  is no longer invoked.

Adds PlurlURLHandlersTest, which starts a framework and asserts that registration
actually happened, that the plurl: protocol resolves, and that shouldHandle does not
claim classes outside this framework.

On JDK 25 the framework suite is at 121 tests with 9 failures, 8 of which are
pre-existing platform specific ones unrelated to this change. The remaining failure is
URLHandlersTest.urlHandlersWithClassLoaderIsolation, discussed on the pull request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@paulrutter

Copy link
Copy Markdown
Contributor Author

Thanks for sorting the headers — apache-rat is satisfied now, which was blocking the build entirely. I've pushed a substantial rework; summary below, with one design question at the end.

I refreshed the vendored sources from upstream, so they include the Apache-2.0 headers from eclipse-osgi-technology/plurl#45 and the fix from eclipse-osgi-technology/plurl#55. Only the package rename is applied. And new PlurlImpl() rather than the ServiceLoader, as you suggested.

Both of your review points were real defects

  • Plurl.install(..) was never called. Since the static Plurl.add(..) helpers operate through a plurl: URL, without installing the router first they fail with unknown protocol: plurl. Nothing in the draft exercised the adapter, so it compiled and the tests passed while being dead code.
  • shouldHandle was too loose. It only checked that a class came from some Felix bundle class loader, so with two frameworks in one JVM either could answer for the other's bundles. It now also requires the owning framework to match, as your EquinoxBundle/container check does.

Plurl is now the mechanism, not an addition alongside it

  • URLHandlers no longer swaps the java.net.URL/URLConnection static fields, and its singleton is not constructed. I had planned to keep that as a fallback, but the catch in your SystemBundleActivator changed my mind: a fallback keeps the sun.misc.Unsafe path alive, which is the thing we're trying to delete. Felix now behaves as Equinox does — if registration fails it logs and that framework contributes no URL handlers.
  • Removing the swap turned out to be necessary, not just tidy. With URLHandlers taking the singletons first, plurl found them occupied and required --add-opens java.base/java.net=ALL-UNNAMED. Installing into a clean JVM uses the supported API and needs no flag.
  • URLHandlersBundleStreamHandler and URLHandlersStreamHandlerProxy now extend PlurlStreamHandlerBase, mirroring your changes to BundleResourceHandler and URLStreamHandlerProxy.
  • The router is installed once per JVM and reference counted. Uninstalling per framework tore it down while another framework was still registered.
  • Added a test that starts a framework and asserts registration actually happened, that the plurl: protocol resolves, and that shouldHandle doesn't claim classes outside the framework.

On Linux the framework suite is 121 tests with a single error, which is the question below.

Routing a URL re-parsed outside any bundle

URLHandlersTest.urlHandlersWithClassLoaderIsolation loads a second copy of the whole framework — and therefore of the vendored plurl classes — in a separate class loader, each with its own framework. It then does the equivalent of:

URL url = bundle.getEntry("...");
new URL(url.toExternalForm()).openStream();   // called from a plain test class

That second new URL(..) is re-parsed without an explicit handler, from a class not loaded by any bundle. There is no bundle frame anywhere on the stack, so shouldHandle cannot attribute it and plurl has nothing to route on.

Note I deliberately do not bind the bundle: handler to one framework the way you bind yours to the container. The JVM caches one handler per protocol, so a pinned handler also gets used for another framework's bundle: URLs. Instead the framework is resolved per call from the UUID in the URL host, via URLHandlers.getFrameworkFromContext(uuid) — which only sees its own copy's frameworks. Previously a second copy reflectively called registerFrameworkListsForContextSearch on whichever copy owned the JVM factory, so UUIDs resolved across class loaders. With plurl there is no "root URLHandlers" to find, so that rendezvous is gone.

I initially assumed Equinox must solve this and Felix was missing something. Reading BundleResourceHandler properly, it's the other way round:

String host = url.getHost();               // "<bundleId>.<container.hashCode()>"
bundleID = parseBundleIDFromURLHost(host); // parses ONLY the bundle id
Module module = getModule(bundleID);       // -> container.getModule(id), the BOUND container

The container is encoded in the host by createURLHostForBundleID, but openConnection parses only the bundle id and resolves it against whichever container the handler was constructed with; the container part is used for equals/hashCode/hostsEqual/sameFile, never to locate the owner. So in this scenario Equinox would either return the resource with that id from the wrong container, or fail with URL_NO_BUNDLE_FOUND. It relies on shouldHandle having routed correctly, with no fallback when attribution is impossible.

Felix's UUID lookup is a stronger guarantee, which is why there's a test for it here and no equivalent upstream. So the question isn't how Equinox does it, but:

Would plurl consider routing a URL to its owning factory, not only a calling Class?

A boolean shouldHandle(URL) on PlurlStreamHandlerFactory, consulted when call stack attribution yields nothing, would let each framework claim its own URLs by inspecting the host. Both frameworks would then be correct for URLs re-parsed outside a bundle, and Equinox could tighten its behaviour if it wanted to.

Without that, the Felix-side options are:

  1. Match Equinox — bind the handler to one framework and accept that cross-framework re-parsing resolves against the wrong one. Converges with upstream, but drops behaviour Felix has today and means weakening or removing that test.
  2. Keep the guarantee with a Felix-side cross-copy UUID registry — which reintroduces reflection into exactly the mechanism plurl is meant to replace, since each class loader has its own copy of both URLHandlers and the plurl classes and there's no shared rendezvous to publish to.

I'd rather not do 1 silently. Happy to raise the API idea as a plurl issue with a concrete proposal if you think it's reasonable.

@tjwatson

tjwatson commented Sep 3, 2026

Copy link
Copy Markdown
Member

Keep the guarantee with a Felix-side cross-copy UUID registry — which reintroduces reflection into exactly the mechanism plurl is meant to replace, since each class loader has its own copy of both URLHandlers and the plurl classes and there's no shared rendezvous to publish to.

I am a bit confused by this because there is a unit test in plurl that mimics a copy scenario and a separate class loader (proxy) scenario. The intent of Plurl is not only can the users have their own copy loaded by a difference class loader, but their own copy could live in a completely different package name. For example. see the test that has a copy of Plurl APIs at

https://github.com/eclipse-osgi-technology/plurl/tree/main/src/test/java/org/eclipse/osgitech/plurl/test/copy

In this case it reflects on agreed upon method names from the Plurl APIs. This way, as long as the copies mirror the upstream Plurl APIs then they can intermix in the same JVM. This way Felix and Equinox can both run in the same JVM and still multiplex on the protocols even if they copied the plurl API/impl into their own felix and equinox packages.

Can you go into more details on what plurl changes you need to get the bundle protocol to work in Felix. Do you need some alternative way to tell plurl about your protocols so that we delegate to the factory for the protocol differently than the default?

paulrutter added a commit that referenced this pull request Sep 3, 2026
This commit touches only the vendored plurl sources and is meant to be read as a
proposal against the upstream plurl project, discussed on
#552. It is separated from the Felix side so
it can be reviewed, or lifted upstream, on its own.

Problem: a factory can currently only be selected from a class on the call stack. If
no class is claimed, "the first factory added is selected". That is arbitrary when
several instances of the same framework are registered, because they all share a
protocol and are told apart only by the URL. It happens in practice when a URL is
re-parsed by a caller that is in no bundle, for example

    new URL(bundleEntryURL.toExternalForm()).openStream();

where nothing on the call stack identifies a framework. Whichever framework
registered first is then asked to resolve another framework's URL. Felix detects that
and fails; a factory that trusts the selection would answer with the wrong resource.

Changes:

- PlurlFactory gains a default shouldHandle(URL) returning false, so existing
  factories are unaffected. A factory that can recognise its own URLs overrides it.
- PlurlImpl.findFactory consults shouldHandle(URL) before walking the call stack,
  reflectively where the factory was compiled against a different copy of the plurl
  package, and tolerating factories that predate the method.
- PlurlFactoryHolder delegates shouldHandle(URL) to the wrapped factory, as it
  already does for shouldHandle(Class). Without this the default would answer for
  every holder and the hook would never reach a factory.
- PlurlRootURLStreamHandler only records a handler in urlToHandler once the URL is
  usable for selection. During parseURL the URL is still being populated and its host
  is not set yet, so a factory cannot claim it and only the call stack is available.
  Caching that choice would pin the URL to a factory chosen before the URL was known;
  not recording it lets the first use of the parsed URL select again.

The last point is the subtle one: without it a shouldHandle(URL) hook is never
reached for a freshly parsed URL, since parseURL runs first and its result is cached
for the life of the URL.

Note shouldHandle(URL) overloads shouldHandle(Class), which makes shouldHandle(null)
ambiguous for callers. A distinct name would avoid that if this is taken upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@paulrutter

paulrutter commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

You're right, my earlier statement was wrong — install() returns early when checkPlurlProtocol() finds an installed plurl, so a second copy registers with the live router rather than replacing it. Nothing in the Felix registration needed changing.

To answer your question: protocol-level delegation wouldn't help, because every Felix framework instance uses the same bundle: protocol. The owner is identified by the framework UUID in the URL host, so selection needs to see the URL. Implemented and split so the plurl part can be lifted on its own:

  • 7f9938cPlurlFactory gains default boolean shouldHandle(URL) returning false; consulted during selection and delegated through PlurlFactoryHolder
  • 5952c24 — narrows it so only a factory that claims a URL can act, keeping the per-URL pinning
  • 392c522 — Felix side: claim bundle: URLs by framework UUID

URLHandlersTest.urlHandlersWithClassLoaderIsolation now passes.

Three things worth knowing, all found by implementing it:

  1. The interface method alone is inert. Selection happens in parseURL, where the JDK has set only protocolhost is populated by parseURL — so there's nothing to inspect, and that choice is then cached for the URL's lifetime. PlurlFactoryHolder also had to delegate the new method, or the default answers for every holder and never reaches a factory.
  2. Nothing changes unless a factory opts in. My first attempt skipped the urlToHandler record while the URL was unparsed, which would have broken Equinox: BundleResourceHandler keeps mutable per-URL state (bundleEntry, cleared in parseURL, used as a fast path in openConnection) that assumes one handler per URL. 5952c24 instead records as before and lets only a claiming factory correct it once. Both Equinox factories declare only shouldHandle(Class), so they never claim and behave identically.
  3. shouldHandle(URL) overloads shouldHandle(Class), making shouldHandle(null) ambiguous — it broke my own test compile. A distinct name would avoid that upstream.

Separately: when no factory claims a URL, declining may be safer than selecting the first factory added, since framework A can be handed a URL naming framework B. Felix's UUID check makes that a clean failure; Equinox resolves only the bundle id against its bound container, so it would answer with its own resource. Happy to leave the fallback as-is if you'd rather.

Glad to raise this as a plurl issue/PR, squashed however you prefer.


Ready-to-apply patch against eclipse-osgi-technology/plurl main, with the package and paths rewritten to the upstream layout. Verified with git apply --check on a fresh clone. Covers both plurl commits above; the Felix side is not included.

plurl-url-selection.patch
diff --git a/src/main/java/org/eclipse/osgitech/plurl/PlurlFactory.java b/src/main/java/org/eclipse/osgitech/plurl/PlurlFactory.java
index 520bf92d97..24aacb75d1 100644
--- a/src/main/java/org/eclipse/osgitech/plurl/PlurlFactory.java
+++ b/src/main/java/org/eclipse/osgitech/plurl/PlurlFactory.java
@@ -26,6 +26,24 @@ package org.eclipse.osgitech.plurl;
  * @see Plurl#add(PlurlStreamHandlerFactory)
  */
 public interface PlurlFactory {
+	/**
+	 * Returns true if this factory should handle the given URL. This is consulted
+	 * before the call stack is examined, and lets a factory claim a URL that only it
+	 * can own, for cases where no class on the call stack identifies the owner. For
+	 * example several instances of the same framework may share a protocol and be
+	 * distinguished only by information in the URL itself.
+	 * <p>
+	 * This is only consulted for URLs that carry enough information to decide, that
+	 * is once the URL has been parsed. A factory that cannot tell from the URL alone
+	 * must return false so that call stack selection is used instead.
+	 *
+	 * @param url the URL a handler is required for
+	 * @return true if this factory should handle the URL
+	 */
+	default boolean shouldHandle(java.net.URL url) {
+		return false;
+	}
+
 	/**
 	 * A plurl implementation will call this method with the classes in the call
 	 * stack which are using the java.net APIs to create URL objects for a specific
diff --git a/src/main/java/org/eclipse/osgitech/plurl/impl/PlurlImpl.java b/src/main/java/org/eclipse/osgitech/plurl/impl/PlurlImpl.java
index 7cbcac6ca2..72e016c8cb 100644
--- a/src/main/java/org/eclipse/osgitech/plurl/impl/PlurlImpl.java
+++ b/src/main/java/org/eclipse/osgitech/plurl/impl/PlurlImpl.java
@@ -820,6 +820,29 @@ public final class PlurlImpl implements Plurl {
 		return null;
 	}
 
+	/**
+	 * Returns the handler of the factory that claims this specific URL, or null when
+	 * no factory claims it. Unlike {@link #findFactory(List)} there is no call stack
+	 * inspection and no fallback to the first factory: this only answers when a
+	 * factory positively identifies the URL as its own.
+	 */
+	PlurlStreamHandler findClaimedPlurlStreamHandler(String protocol, URL url) {
+		if (!isUsableForSelection(url)) {
+			return null;
+		}
+		List<URLStreamHandlerFactoryHolder> factories = getURLStreamHandlerFactories();
+		if (factories.size() < 2) {
+			// nothing to disambiguate
+			return null;
+		}
+		for (URLStreamHandlerFactoryHolder f : factories) {
+			if (shouldHandleURL(f, url)) {
+				return f.getHandler(protocol);
+			}
+		}
+		return null;
+	}
+
 	private <F> F findFactory(List<F> factories) {
 		int numFactories = factories.size();
 		if (numFactories == 1) {
@@ -855,6 +878,43 @@ public final class PlurlImpl implements Plurl {
 		return numFactories > 0 ? factories.get(0) : null;
 	}
 
+	/**
+	 * A URL can only be used for selection once it has been parsed. During
+	 * {@code parseURL} the URL is still being populated, so there is nothing to
+	 * decide on yet.
+	 */
+	private static boolean isUsableForSelection(URL url) {
+		if (url == null) {
+			return false;
+		}
+		String host = url.getHost();
+		return (host != null) && !host.isEmpty();
+	}
+
+	private <F> boolean shouldHandleURL(F f, URL url) {
+		if (f instanceof PlurlFactory) {
+			return ((PlurlFactory) f).shouldHandle(url);
+		}
+		// use reflection in case this Plurl package isn't visible to the factory impl,
+		// and tolerate copies that predate this method
+		try {
+			Method m = findShouldHandleURL(f.getClass());
+			return (m == null) ? false : (boolean) m.invoke(f, url);
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	Method findShouldHandleURL(Class<?> clazz) {
+		try {
+			Method shouldHandle = clazz.getMethod("shouldHandle", URL.class); //$NON-NLS-1$
+			shouldHandle.setAccessible(true);
+			return shouldHandle;
+		} catch (NoSuchMethodException e) {
+			return null;
+		}
+	}
+
 	Method findShouldHandle(Class<?> clazz) throws NoSuchMethodException {
 		Method shouldHandle = null;
 		try {
@@ -944,6 +1004,18 @@ public final class PlurlImpl implements Plurl {
 			return ((PlurlFactory) f).shouldHandle(clazz);
 		}
 
+		@Override
+		public boolean shouldHandle(URL url) {
+			F f = factory.get();
+			if (f == null) {
+				return false;
+			}
+			// Delegate to the wrapped factory, reflectively when this plurl package is
+			// not the one the factory was compiled against, and tolerate factories
+			// that predate this method.
+			return shouldHandleURL(f, url);
+		}
+
 		H getHandler(String type) {
 			final F f = factory.get();
 			if (f == null) {
@@ -1419,7 +1491,18 @@ public final class PlurlImpl implements Plurl {
 				// Record the handler found for the URL;
 				// This allows to consistently use the same handler for the
 				// life of the URL object when we are multiplexing.
-				return urlToHandler.get(u, this::findPlurlStreamHandlerImpl);
+				PlurlStreamHandler recorded = urlToHandler.get(u, this::findPlurlStreamHandlerImpl);
+				// The recorded handler may have been chosen during parseURL, before the
+				// URL was populated and therefore before any factory could claim it.
+				// Once the URL can be claimed, let its owner correct the record; from
+				// then on it stays pinned as before. Factories that do not implement
+				// shouldHandle(URL) never claim, so nothing changes for them.
+				PlurlStreamHandler claimed = findClaimedPlurlStreamHandler(protocol, u);
+				if (claimed != null && claimed != recorded) {
+					urlToHandler.replace(u, claimed);
+					return claimed;
+				}
+				return recorded;
 			}
 			return findPlurlStreamHandlerImpl();
 		}
diff --git a/src/main/java/org/eclipse/osgitech/plurl/impl/URLToHandler.java b/src/main/java/org/eclipse/osgitech/plurl/impl/URLToHandler.java
index d17b2b40f7..da5016654d 100644
--- a/src/main/java/org/eclipse/osgitech/plurl/impl/URLToHandler.java
+++ b/src/main/java/org/eclipse/osgitech/plurl/impl/URLToHandler.java
@@ -52,6 +52,21 @@ public class URLToHandler {
 
 	Map<WeakURL, PlurlStreamHandler> entries = Collections.synchronizedMap(new HashMap<>());
 
+	/**
+	 * Replaces the handler recorded for the given URL. Used when the handler first
+	 * recorded was chosen before the URL was populated, and its owner has since
+	 * claimed it.
+	 */
+	void replace(URL u, PlurlStreamHandler handler) {
+		synchronized (entries) {
+			entries.put(new WeakURL(u, queue), handler);
+			Object x;
+			while ((x = queue.poll()) != null) {
+				entries.remove(x);
+			}
+		}
+	}
+
 	PlurlStreamHandler get(URL u, Supplier<PlurlStreamHandler> h) {
 		WeakURL lookup = new WeakURL(u, null);
 		PlurlStreamHandler existing = entries.get(lookup);

@paulrutter
paulrutter force-pushed the feature/FELIX-6759-plurl branch from b5bbd43 to c3094d7 Compare September 3, 2026 19:50
@tjwatson

tjwatson commented Sep 3, 2026

Copy link
Copy Markdown
Member

Open an issue against eclipse-osgi-technology/plurl and you can attach your PR there also. That way we can discuss possible API additions there. Thanks for looking at this.

@paulrutter

Copy link
Copy Markdown
Contributor Author

Open an issue against eclipse-osgi-technology/plurl and you can attach your PR there also. That way we can discuss possible API additions there. Thanks for looking at this.

Thanks! Will do and continue the discussion there.
With the proposed patch applied, all unit tests on the Felix side now succeed.
I just have some remaining cleanup todo for Unsafe usage in the meantime.

@tjwatson

tjwatson commented Sep 3, 2026

Copy link
Copy Markdown
Member

Because I've already released a version of Equinox with the current Plurl signatures I will need to attempt to make any new implementation with the new APIs still be able to handle when copies of the old APIs are still in use in the same JVM instance. That may become impossible over time, but I want to at least try to provide some compatibility between versions of plurl.

Otherwise it will become increasingly difficult to be able to update a core framework implementation which hosts other applications which may embed their own version of a framework implementation (Equinox or Felix). Think of a WAR that uses osgi connect to compose their application components using felix or equinox in their WEB-INF/lib folder. And then that WAR being deployed to a server which is implemented with OSGi, such as glassfish(using felix) or liberty(using equinox).

@paulrutter

paulrutter commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

One correction before I open the plurl issue: the shouldHandle(URL) shape I proposed doesn't work, and it took running plurl's own test suite to see why.

PlurlRootURLStreamHandler.parseURL pins the selected handler onto the URL through the private java.net.URL.handler field. Wherever that field is writable — plurl's own test build passes --add-opens java.base/java.net, and the Felix framework bundle declares Add-opens: java.base/java.net — the swap succeeds, plurl steps aside, and openConnection never comes back through it. So a URL-based hook is unreachable in exactly the deployments that need it. My Felix test passed only because that JVM had no add-opens, which is the opposite of reassuring.

Selection has to be decidable at the moment plurl chooses, i.e. mid-parse, when the URL has no host yet. The spec does exist then:

default boolean shouldHandle(String protocol, String spec) { return false; }

consulted in findFactory before the call stack is walked, with PlurlFactoryHolder delegating it the way it already delegates shouldHandle(Class) — without that, findFactory only ever asks the holders, which answer with the interface default.

On your compatibility point: the signature uses only java.lang types, so the reflective path finds it on a factory compiled against a different copy of the package, and a factory that predates the method is tolerated rather than throwing — the lookup is cached, since it sits on the URL parsing path. Existing factories are unaffected either way. To be straight about the limit though: this only helps when the installed router is new enough to consult it. If an older plurl wins the install, Felix falls back to today's behaviour, and nothing on the factory side can change that.

  • 81442bc — the plurl change, vendored copy only
  • 07d572a — the Felix side, now claiming by UUID read from the spec

51/51 in the plurl repo, including a new PlurlURLSelectionTest that fails without the patch (both URLs served by the first-added factory) and passes with it. Felix's suite is unchanged. I'll raise the issue and PR upstream with that test included.

plurlselectfactorybyspec.patch

@paulrutter

Copy link
Copy Markdown
Contributor Author

Raised upstream: plurl#62 with plurl#63 attached, including the test.

@paulrutter

Copy link
Copy Markdown
Contributor Author

Added the silent-degradation warning, and the upstream half that makes it possible — plurl#63 now has a second commit.

The problem: the plurl copy that wins the install in a JVM is the one that routes, and it may be older than the copy this framework brought. We register successfully and are then silently misrouted, with no way to tell — bundle: URLs can only be attributed by the UUID they carry, so without shouldHandle(protocol, spec) they go to whichever factory registered first.

Upstream now answers a plurlCapabilities query. An implementation predating it rejects the operation with IOException, so absence of an answer is the answer and needs no cooperation from already-released plurl.

  • 1947fc4 — vendored plurl change
  • 45b65b6 — Felix warns at startup when the installed router can't route bundle: URLs, instead of leaving it to surface later as a failed resource lookup. The test pins the vendored copy's capability, so re-vendoring an older plurl fails there rather than quietly turning the warning on for everyone.

Also cdd41d1: the vendored plurl sources carry Copyright (c) Contributors to the Eclipse Foundation and were credited in no legal file — not NOTICE, not LICENSE, not DEPENDENCIES. Added the NOTICE entry alongside the existing OSGi Alliance one, which is the same case; no LICENSE change needed since plurl is ALv2 too. RAT doesn't catch this, it only checks source headers.

Framework suite at the pre-existing baseline. @tjwatson no action needed on the Felix commits, but the capability commit upstream is take-it-or-leave-it — the selection commit stands on its own without it.

@paulrutter paulrutter changed the title FELIX-6759 - PROTOTYPE: use plurl for URL handler factory multiplexing FELIX-6759 - Remove Unsafe - use plurl for URL handler factory multiplexing Sep 4, 2026
@paulrutter paulrutter changed the title FELIX-6759 - Remove Unsafe - use plurl for URL handler factory multiplexing FELIX-6759 - Remove sun.misc.Unsafe - use plurl for URL handler factory multiplexing Sep 4, 2026
@stbischof

Copy link
Copy Markdown
Contributor

Plur snapshot is available.

Ci build is fully working

@paulrutter
paulrutter marked this pull request as ready for review September 6, 2026 19:15
@paulrutter

Copy link
Copy Markdown
Contributor Author

Plur snapshot is available.

Ci build is fully working

I'm proposing a few upstream tweaks so it also covers all Felix needs, see attached PR for plurl.

plurl multiplexes the JVM URL factory singletons through the supported
URL.setURLStreamHandlerFactory API, so the framework no longer has to claim
them by reflectively clearing private static fields of java.net.URL. Equinox
uses the same mechanism, so a JVM hosting both can share one router.

The sources are vendored under org.apache.felix.framework.plurl, byte for byte
as upstream except the package rename, because plurl has not been released to
Maven Central yet. Once it is, this becomes a provided dependency embedded with
Private-Package, the way org.apache.felix.resolver already is, and these files
go away.

Includes the URL selection support the framework needs, which is merged
upstream as eclipse-osgi-technology/plurl#63: every framework instance uses the
same bundle: protocol, so the owner can only be identified by the UUID in the
URL, which the call stack cannot supply when a bundle: URL is parsed by a
caller that is in no bundle.

Apache-2.0, credited in the framework NOTICE alongside the OSGi Alliance entry.
See org/apache/felix/framework/plurl/README.md for provenance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paulrutter and others added 2 commits September 9, 2026 09:15
Each framework instance registers its own factory with the plurl router instead
of this class installing itself as the JVM stream and content handler factory.
Registering uses the supported URL.setURLStreamHandlerFactory API on a clean
JVM, so no reflective write to a java.net static field is left anywhere in the
framework.

Failing to register does not prevent the framework from starting: as in
Equinox, the consequence is that this instance contributes no URL handlers.
There is deliberately no fallback to swapping the singleton fields, since
removing that is the point.

bundle: URLs are claimed by the framework UUID in the URL, read from the spec
because plurl has to select a factory before the URL is parsed. Every framework
instance uses the same bundle: protocol, so neither the protocol nor the call
stack identifies an owner when a bundle: URL is parsed by a caller that is in no
bundle. A null spec is the protocol level question plurl asks before any URL
exists, and is answered by claiming the protocol.

The framework warns at startup when the installed plurl does not support
selection by URL, because bundle: URLs are then routed to whichever factory
registered first and cannot be resolved. Nothing on the factory side can fix
that, so it is reported rather than left to surface later as a failed resource
lookup.

With plurl owning the JVM factories, URLHandlers is never instantiated, so the
dead instance side is removed with it: the constructor that performed the
takeover, the cross-classloader framework list rendezvous that plurl now does,
the URLStreamHandlerFactory and ContentHandlerFactory implementations, and the
built-in handler lookup and caches. What remains is the framework registry that
URLHandlersBundleStreamHandler uses to find the owner of a caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…efused

Fixes the warning reported in this issue: starting Felix on JDK 24 or later
printed that sun.misc.Unsafe::staticFieldOffset is terminally deprecated and
will be removed.

SecureAction loses swapStaticFieldIfNotClass and flush, which had no callers
left once URLHandlers stopped taking over the JVM factories.

What remains of Unsafe is getAccessor, which is not a memory access shim that
can be ported to VarHandle: it defines an accessor class inside java.base so
that setAccessible has an in-module caller, and closing that off is the intent
of the JDK's integrity work. It is now reached only when ordinary reflection
has already been refused, so where the package is open -- the Add-opens of the
org.apache.felix.main launcher, or any --add-opens on the command line -- the
deprecated method is never called.

ClassPathExtenderFactory resolved URLClassLoader.addURL and
ClassLoaders$AppClassLoader.appendToClassPathForInstrumentation in its static
initialiser and made both accessible there. That runs from
ExtensionManager.<clinit>, so every framework instance paid for it whether or
not it ever installs an extension bundle. Looking a method up needs no access,
only setAccessible does, so the lookup stays and the access is requested in
add(File), where the method is invoked. A framework that installs no extension
bundle now never calls setAccessible on a java.base member.

Verified on JDK 25 with --sun-misc-unsafe-memory-access=deny: the framework
starts and only framework extension bundles fail, cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@paulrutter
paulrutter force-pushed the feature/FELIX-6759-plurl branch from 34e7dbb to 26ac5e6 Compare September 9, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants