Skip to content

FELIX-6759 - PROTOTYPE: use plurl for URL handler factory multiplexing - #552

Draft
paulrutter wants to merge 21 commits into
feature/FELIX-6759-Java-25-LTSfrom
feature/FELIX-6759-plurl
Draft

FELIX-6759 - PROTOTYPE: use plurl for URL handler factory multiplexing#552
paulrutter wants to merge 21 commits into
feature/FELIX-6759-Java-25-LTSfrom
feature/FELIX-6759-plurl

Conversation

@paulrutter

Copy link
Copy Markdown
Contributor

Warning

Draft / prototype — not for merge. There is an unresolved licensing question below that blocks this as it stands.

Prototype exploring @tjwatson's suggestion in #433 (comment) that Felix and Equinox should share a cooperative mechanism for the java.net.URL factory singletons. Branches off, and targets, #433.

The problem

URLHandlers claims the JVM-wide URLStreamHandlerFactory by reflectively swapping a private static field on java.net.URL, via SecureAction.swapStaticFieldIfNotClass. Obtaining a MethodHandles.Lookup trusted enough to do that is the only remaining reason the framework uses sun.misc.Unsafe — the staticFieldOffset/getObject steal of IMPL_LOOKUP. It also means whichever framework installs itself last wins the singleton, so Felix and Equinox cannot coexist in one JVM without clobbering each other.

To be clear about urgency: this is not a JDK 25 blocker. That Unsafe block is guarded by catch (Throwable) and falls back to AccessibleObject.setAccessible, so on JDK 25 it only produces the familiar warning. This is about JDK 26+ and about framework interop.

What this does

  • Vendors the 11 plurl sources from eclipse-osgi-technology/plurl (commit 6581777) into org.apache.felix.framework.plurl. The only change is the package rename; every license header and copyright notice is preserved verbatim. This mirrors what Equinox did in Plurl API for managing Java singleton factories in URL eclipse-equinox/equinox#848.
  • Adds PlurlURLHandlers, adapting URLHandlers to PlurlStreamHandlerFactory / PlurlContentHandlerFactory. Plurl installs one cooperative router through the supported URL.setURLStreamHandlerFactory API and routes by asking each registered factory shouldHandle(Class) — which maps directly onto what URLHandlers.getFrameworkFromContext() already does by hand, and needs no Unsafe.

Verified on JDK 25: the framework compiles and its test results are unchanged.

⚠️ Licensing — @tjwatson could you confirm?

Every vendored source file declares:

SPDX-License-Identifier: EPL-2.0
Copyright (c) 2025 IBM Corporation

but plurl's own LICENSE file and its pom.xml both declare Apache-2.0. (Its NOTICE also still refers to "slf4j-osgi", which looks like a copy-paste leftover.)

This matters because of where it is being copied to. EPL-2.0 is Category B at the ASF and must not be included in an Apache source release. Equinox was free to vendor these files because Eclipse projects are EPL-2.0 natively; Felix is not.

Given the repo's LICENSE and pom say Apache-2.0, my guess is the EPL headers are carried over from the original Equinox code rather than intended. Could you confirm the intended license and, if it is Apache-2.0, correct the source headers upstream? That would unblock the vendoring approach for Felix and anyone else.

If the headers are intentionally EPL-2.0, then Felix cannot vendor the sources and the options become:

  1. consume plurl as a released binary dependency under the Category B rules — which additionally needs plurl published to Maven Central, as it currently has no release or tag; or
  2. write an Apache-2.0 implementation of the same design in Felix, which works but forks the effort rather than converging on the shared library.

cc @stbischof @laeubi @mattrpav

🤖 Generated with Claude Code

Not for merge. This explores the suggestion in
#433 (comment) that Felix and
Equinox should share a cooperative way to manage the java.net.URL factory singletons.

URLHandlers currently claims the JVM-wide URLStreamHandlerFactory by reflectively
swapping a private static field on java.net.URL
(SecureAction.swapStaticFieldIfNotClass). Getting a MethodHandles.Lookup trusted
enough to do that is the only remaining reason the framework uses sun.misc.Unsafe,
and it means the last framework to install itself wins the singleton, so two
frameworks cannot coexist in one JVM without clobbering each other.

Plurl installs a single cooperative router through the supported
URL.setURLStreamHandlerFactory API and lets any number of parties register with it;
each registered factory answers shouldHandle(Class) to claim the calling class. That
maps onto what URLHandlers.getFrameworkFromContext() already does by hand, and it
needs no Unsafe.

- Vendors the 11 plurl sources from eclipse-osgi-technology/plurl (commit 6581777)
  into org.apache.felix.framework.plurl. The only change is the package rename;
  every license header and copyright notice is preserved verbatim. This mirrors
  eclipse-equinox/equinox#848, which vendored the same files
  into org.eclipse.equinox.plurl.
- Adds PlurlURLHandlers, adapting URLHandlers to PlurlStreamHandlerFactory and
  PlurlContentHandlerFactory, with shouldHandle(Class) recognising this framework's
  bundle class loaders.

UNRESOLVED LICENSING QUESTION: every vendored file declares
"SPDX-License-Identifier: EPL-2.0" and "Copyright (c) 2025 IBM Corporation", while
plurl's own LICENSE and pom.xml declare Apache-2.0. EPL-2.0 is Category B at the ASF
and may not be included in an Apache source release, so this cannot be merged as it
stands. See framework/src/main/java/org/apache/felix/framework/plurl/README.md.

Verified on JDK 25: the framework compiles and its tests are unchanged
(the same 8 pre-existing platform specific failures as before this commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

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 and others added 4 commits September 3, 2026 21:07
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>
Uses the plurl shouldHandle(URL) hook added in the previous commit, which is the
Felix half of that proposal.

Every Felix framework instance in a JVM uses the same bundle: protocol, so the
protocol alone does not identify an owner; the framework UUID in the URL host does.
PlurlURLHandlers.shouldHandle(URL) compares that UUID against its own framework, so
a bundle: URL is routed to the framework that created it even when nothing on the
call stack identifies one.

That is what URLHandlersTest.urlHandlersWithClassLoaderIsolation exercises: two
copies of the framework in separate class loaders, each with its own framework, and a
URL re-parsed from a plain test class. Previously the first registered factory was
selected and could not resolve the other framework's UUID, failing with "No framework
context found". It now passes.

On JDK 25 the framework suite is 121 tests with 8 failures, all of them pre-existing
platform specific ones unrelated to this work, and no errors. URLHandlersTest,
ResourceLoadingTest and PlurlURLHandlersTest all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
apache-rat flagged the README as the only unapproved file in the framework, since a
Markdown file needs the header as an HTML comment. This is our own file, so the header
belongs there rather than in the rat excludes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refines the previous plurl commit so that nothing changes for a factory that does not
implement shouldHandle(URL). Raised because Equinox depends on the behaviour the
first version altered.

The first version skipped recording a handler in urlToHandler while the URL was still
being parsed, so that the first use of the parsed URL selected again. That weakened a
guarantee plurl documents and Equinox relies on: BundleResourceHandler keeps mutable
per URL state (its bundleEntry field, cleared in parseURL and used as a fast path in
openConnection), which assumes the same handler serves a URL from parse through to
use. Re-selecting could also route to a different container when the call stack at
first use differs from the call stack at parse time.

Instead the handler is recorded during parseURL exactly as before, and only a factory
that positively claims the URL may correct that record, once, after which it stays
pinned as before:

- findFactory is restored to its original form, so URL based selection no longer
  affects the general path.
- findClaimedPlurlStreamHandler answers only when a factory claims the URL. There is
  no call stack inspection and no fallback to the first factory, so it returns null
  unless an owner positively identifies the URL as its own.
- URLToHandler.replace updates the record for that URL.

A factory that does not implement shouldHandle(URL) never claims, so the recorded
handler is never replaced and behaviour is identical to today. That includes both
Equinox factories, which declare only shouldHandle(Class).

Verified on JDK 25: URLHandlersTest.urlHandlersWithClassLoaderIsolation still passes
and the framework suite is unchanged at 121 tests with no errors.

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 and others added 2 commits September 3, 2026 22:32
Replaces the earlier shouldHandle(URL) proposal, which cannot work: plurl
pins its chosen handler onto the URL in parseURL via reflection into the
private java.net.URL handler field, so once that field is writable (any JVM
run with --add-opens java.base/java.net) plurl steps out of the way and never
regains control at openConnection. The hook would never be reached in exactly
the deployments that matter -- the Felix framework bundle declares
Add-opens: java.base/java.net.

Selection has to be decidable at the moment plurl chooses, which is while the
URL is still being parsed and has no host or path yet. The spec does exist at
that point, so:

    default boolean shouldHandle(String protocol, String spec)

consulted in findFactory before the call stack is walked. A protocol may be
shared by several factories that are distinguishable only by the URL (multiple
framework instances, where the owner is an id in the host), and such a URL may
be parsed by a caller that no factory recognises from the call stack, leaving
nothing else to select on.

Defaulting to false leaves existing factories unaffected. The signature uses
only java.lang types, so the reflective path finds it on factories compiled
against a different copy of the plurl package, and a factory that predates the
method is tolerated rather than failing -- both needed while released plurl
signatures stay in use in the same JVM. The lookup is cached, because it sits
on the URL parsing path.

PlurlFactoryHolder delegates the new method the same way it already delegates
shouldHandle(Class); without that, findFactory only ever asks the holders,
which answer with the interface default.

Verified in the plurl repo itself: 51 tests pass, including a new test that
fails on unpatched plurl.

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

Follows the plurl change from shouldHandle(URL) to shouldHandle(protocol,
spec). Every Felix framework in the JVM uses the same bundle: protocol, so the
protocol alone does not identify an owner -- the framework UUID in the host
does. The URL cannot be asked for its host, because plurl has to pick a
factory before the URL is parsed, so the host is read out of the spec.

Adds a test for the hook, covering another framework's UUID, a port in the
host, a non-bundle protocol and a spec with no host.

URLHandlersTest.urlHandlersWithClassLoaderIsolation passes, and the suite is
at the pre-existing baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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 and others added 6 commits September 3, 2026 22:45
Now that plurl installs the JVM factories, URLHandlers is never instantiated:
its private constructor was the only caller of the reflective field clearing,
and nothing constructs it any more. Everything reachable only from there was
dead, including the whole cross-classloader rendezvous it existed to support
-- registerFrameworkListsForContextSearch published the framework list to a
URLHandlers in another classloader, which plurl now does properly. The class
keeps only the framework registry that URLHandlersBundleStreamHandler needs to
find the owner of a caller when a handler is not bound to one.

Removed: the constructor and its handler pre-loading, the two context-search
registration methods, the URLStreamHandlerFactory and ContentHandlerFactory
implementations (the class is no longer either), the built-in handler lookup
and the handler caches. SecureAction loses swapStaticFieldIfNotClass and
flush, which had no callers left.

This removes every reflective write to a java.net static field. One
sun.misc.Unsafe use remains in SecureAction.getAccessor, which is a different
concern: it is the setAccessible strategy for deep reflection, still needed by
addURLToURLClassLoader when installing framework extension bundles.

No behaviour change intended in what remains: registerFrameworkInstance and
unregisterFrameworkInstance still perform exactly one list update and one
counter step per call, as the lock dance around the removed registry did.

Suite is at the pre-existing baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SecureAction.getAccessor exists to defeat JPMS encapsulation, not because
setAccessible is unavailable: accessor.bytes is a class declared "package
java.net", injected into java.base through a trusted MethodHandles.Lookup
stolen with sun.misc.Unsafe, so that the setAccessible call comes from inside
that module and the access check passes without the package being open.

It was used unconditionally for java.net and jdk.internal.loader, and primed
eagerly in the static initialiser, so every framework start paid for it. On
JDK 25 that means every start prints

  WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
  WARNING: sun.misc.Unsafe::staticFieldOffset has been called by
           org.apache.felix.framework.util.SecureAction
  WARNING: sun.misc.Unsafe::staticFieldOffset will be removed in a future
           release

Now plain setAccessible is tried first and Unsafe is the fallback for when the
JVM actually refuses. 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 at all. The eager priming is dropped, since
the accessor is built on demand.

Unsafe cannot simply be removed: verified on JDK 25 that without --add-opens,
setAccessible on URLClassLoader.addURL throws InaccessibleObjectException, and
ClassPathExtenderFactory needs that and
ClassLoaders$AppClassLoader.appendToClassPathForInstrumentation to install
framework extension bundles.

Suite is at the pre-existing baseline on JDK 21, and ExtensionManagerTest
still installs extensions on JDK 25 through the fallback.

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

CI on JDK 25 still attributed two terminal deprecation warnings to Felix, one
from the unit tests and one from the TCK run, i.e. from a real framework start:

  WARNING: sun.misc.Unsafe::staticFieldOffset has been called by
           org.apache.felix.framework.util.SecureAction

DefaultClassLoaderExtender 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, and on a JVM where those packages
are not open the access is served by defining an accessor inside java.base
with sun.misc.Unsafe.

Looking a method up needs no access -- verified on JDK 25 with Unsafe denied
and no --add-opens, getDeclaredMethod succeeds for both while setAccessible
throws InaccessibleObjectException. So only the lookup stays in the static
initialiser, which is all getExtender needs to report whether it can extend,
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, and never touches Unsafe. Verified on JDK 25: no
warning is attributed to Felix by StartStopBundleTest or ServiceObjectsTest,
while ExtensionManagerTest still installs extensions and produces exactly one,
at the point the extension is installed.

Suite is at the pre-existing baseline on JDK 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vendored plurl sources carry "Copyright (c) Contributors to the Eclipse
Foundation" under the Apache License 2.0, and were not recorded in NOTICE,
LICENSE or DEPENDENCIES. Bundled third-party code has to be attributed; the
OSGi Alliance entry already in this NOTICE is the same case. No LICENSE change
is needed, since plurl is under the same license as the product.

Also drops the PROTOTYPE marker from the PlurlURLHandlers javadoc.

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

The plurl copy that wins the install in a JVM is the one that routes, and it
may be older than the copy a factory brought with it -- another framework
instance, or an application embedding its own. A factory registering with such
a router has no way to know that a capability it depends on will never be
consulted, so it registers and is then silently misrouted.

That matters here: bundle: URLs can only be attributed to a framework by the
UUID they carry, so without shouldHandle(String, String) they go to whichever
factory registered first.

Adds a plurlCapabilities operation reporting a Set<String>, and
Plurl.capabilities() as the convenience form. An implementation predating the
operation rejects it with an IOException, so absence of an answer is the
answer and is reported as an empty set. Also tolerates a leading slash in the
operation path, so the documented "plurl://op/<operation>" form works and is
not mistaken for an older plurl.

Pushed upstream as part of eclipse-osgi-technology/plurl#63. 54 tests pass
there, including one covering this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
If the plurl that won the install in the JVM does not consult
shouldHandle(protocol, spec), a bundle: URL parsed by a caller outside any
bundle is handed to whichever factory registered first, which cannot resolve
another instance's UUID. Nothing on the factory side can fix that, so it is
reported at startup instead of surfacing later as a failed resource lookup.

The test pins the capability of the vendored copy, so that re-vendoring an
older plurl fails there rather than quietly turning this warning on for
everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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.

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.

2 participants