Repository navigation
Fallback isolated ClassLoader to platform classes - #340
tonygermano merged 1 commit into
Conversation
14545be to
9e5aff1
Compare
|
Can you post repro steps for testing purposes? |
|
Sure thing @mgaffigan . I've edited it into the issue description (#338) for better visibility. |
9e5aff1 to
dabc06b
Compare
|
Would this affect cases where a user has classes in a resource directory that includes libraries that do conflict with the parent classloader? I reviewed this issue by reading the docs for how the URLClassloader works for both old and new versions of Java:
Since the behaviour hasn't changed in 18 major versions I think we can safely assume that it won't break anything in the future :D I read the references in the PR description. This change is logical and correct. If a driver is loaded, that driver will have dependencies in the system classpath. Therefore including the system classpath is appropriate. HOWEVER see my question above. What happens if a user needs the classloader specifically to override something on the system classpath? Is this common enough to worry about? |
|
Hi @jonbartels, thanks for taking time to review! I believe that this is why the "Load Parent-First" option was introduced - it allows us to choose whether each Resource is meant to take priority during class loading. The cases you ask to consider should be solvable by making use of this checkbox - although they should be uncommon since the move from Java 8 began quite recently.
|
tonygermano
left a comment
There was a problem hiding this comment.
I believe this is the correct change to make so that it behaves similarly to how it ran under java 8. The change itself would have been difficult to make prior to bumping the minimum version to java 17 because the method did not exist in java 8; it would have needed to test if it existed and call it by reflection.
I tested using the driver from https://h2database.com/html/main.html
- added the driver as a resource (did not check load parent first)
- added a database reader channel
- added the driver resource to the channel
- specified the driver as
org.h2.Driver - specified the jdbc uri as
jdbc:h2:./appdata/h2.db - clicked the
Generate: Selectbutton above the SQL section - clicked the
Get Tablesbutton
Under the main branch, this threw a ClassNotFound Exception for java.sql.Driver.
With the fix from this branch
- no error is thrown
- no tables are returned (I never created any)
- a non-empty database file is created at the location specified in the appdata folder
@jonbartels The The The |
jonbartels
left a comment
There was a problem hiding this comment.
My quesitons were addressed. LGTM!
|
@jonbartels I did some additional testing. This is still not working 100% as I would expect, but it is better than it was before, and I don't believe the unexpected behavior is related to this PR, so I am not retracting my approval. I made a channel with a database/js writer containing: var dbConn;
try {
dbConn = DatabaseConnectionFactory.createDatabaseConnection('org.postgresql.Driver','jdbc:postgresql://localhost:5432/postgres','postgres','test');
return dbConn.connection.metaData.driverVersion
} finally {
if (dbConn) {
dbConn.close();
}
}
So far, this is all as it should be, but the part that was unexpected was that the 42.7.13 driver was still active when "load parent first" was chosen on the resource. I suspect it may have something to do with how DriverManager works. |
|
Two asks before merge:
isolated.loadClass("java.sql.Driver"); // CNFE without this fix
assertThrows(ClassNotFoundException.class,
() -> isolated.loadClass("com.mirth.connect.server.util.javascript.MirthContextFactory"));Pre-existing issues found while reviewing (unclosed loaders on resource reload, Method note, because I'd like to see more AI-assisted review here: this ran through Claude Code on Fable 5 with ultracode on, using my irritable-developer-check skill, a grumpy Java/Mirth review checklist authored, in every way that matters, by all of you. Plus a ponytail pass for over-engineering and a security review, with every finding adversarially re-verified against the code before posting. Two findings didn't survive that verification and were dropped, which is rather the point. Happy to share the setup with anyone interested. |
7e24227
|
In response to @pacmano1:
Updated the Javadoc. Please review.
In progress... @tonygermano so it appears that Load Parent-First actually does not apply to the isolated classloader which is used when Resources are attached to the channel. This would explain why you are seeing |
pacmano1
left a comment
There was a problem hiding this comment.
Reviewing 7e24227 as requested.
Javadoc: one real error, System.getPlatformClassLoader() doesn't exist. The method is on ClassLoader. While fixing that, suggested wording that explains the behavior instead of just naming the parent, since this package is the published User API and this method is exactly where the confusion in this thread lived:
/**
* Returns a classloader containing only the libraries in the custom resources assigned to the
* current context. Use it to load classes from those libraries in isolation, for example a
* specific JDBC driver version, without interference from the versions the server itself ships.
* <p>
* Core Java classes (for example {@code java.sql} or {@code javax.xml}) are visible through this
* classloader, but classes from the server or its plugins are not (its parent is
* {@link ClassLoader#getPlatformClassLoader()}). A class that exists in both a custom resource
* and the JDK resolves to the JDK's copy. The "Load Parent-First" option on a resource does not
* affect this classloader; it applies only to the classloader returned by {@link #getClassLoader()}.
*
* @return A classloader containing only the custom resource libraries, or null if the current
* context has no custom resources.
*/@paul-hristea for the in-progress test, here's one you're welcome to take as-is: server/src/test/java/com/mirth/connect/server/util/javascript/MirthContextFactoryTest.java. Before suggesting it I ran it against every parent someone might plausibly put on that line, on this branch:
MirthContextFactory.java:117 parent |
./gradlew :server:test --tests '*MirthContextFactoryTest' |
|---|---|
ClassLoader.getPlatformClassLoader() (this PR) |
passes, 2/2 |
null (the #338 regression) |
fails, ClassNotFoundException on java.sql.Driver |
Thread.currentThread().getContextClassLoader() |
fails, assertThrows catches the leaked server class |
ClassLoader.getSystemClassLoader() |
fails, same |
So it accepts exactly the parent this PR introduces and rejects the three wrong ones, including the two that the H2/Postgres live tests can't distinguish from correct. Reproduce by swapping the parent on line 117 and rerunning.
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: Open Integration Engine
package com.mirth.connect.server.util.javascript;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.net.URL;
import java.util.HashSet;
import org.junit.BeforeClass;
import org.junit.Test;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import com.mirth.connect.server.builders.JavaScriptBuilder;
import com.mirth.connect.server.controllers.CodeTemplateController;
import com.mirth.connect.server.controllers.ConfigurationController;
import com.mirth.connect.server.controllers.ControllerFactory;
import com.mirth.connect.server.controllers.EventController;
import com.mirth.connect.server.controllers.ExtensionController;
public class MirthContextFactoryTest {
@BeforeClass
public static void setUpBeforeClass() {
// Same mocked ControllerFactory pattern as JavaScriptUtilTest, so this class is
// self-sufficient regardless of which test classes ran (and injected) before it.
ControllerFactory controllerFactory = mock(ControllerFactory.class);
EventController eventController = mock(EventController.class);
when(controllerFactory.createEventController()).thenReturn(eventController);
ConfigurationController configurationController = mock(ConfigurationController.class);
when(controllerFactory.createConfigurationController()).thenReturn(configurationController);
ExtensionController extensionController = mock(ExtensionController.class);
when(controllerFactory.createExtensionController()).thenReturn(extensionController);
CodeTemplateController codeTemplateController = mock(CodeTemplateController.class);
when(controllerFactory.createCodeTemplateController()).thenReturn(codeTemplateController);
Injector injector = Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
requestStaticInjection(ControllerFactory.class);
bind(ControllerFactory.class).toInstance(controllerFactory);
}
});
injector.getInstance(ControllerFactory.class);
JavaScriptBuilder.setControllersForTesting(extensionController, codeTemplateController);
}
/*
* Regression test for #338: with a null parent, the isolated classloader cannot see java.sql
* on Java 9+, so custom driver resources failed to deploy. The parent must be the platform
* classloader: JRE classes visible, server classpath not.
*/
@Test
public void isolatedClassLoaderResolvesPlatformButNotServerClasses() throws Exception {
URL dummyJar = new File("build/tmp/mirth-context-factory-test-dummy.jar").toURI().toURL();
MirthContextFactory contextFactory = new MirthContextFactory(new URL[] { dummyJar }, new HashSet<>(), false);
ClassLoader isolated = contextFactory.getIsolatedClassLoader();
assertNotNull(isolated);
// Fails with ClassNotFoundException if the parent ever goes back to null
isolated.loadClass("java.sql.Driver");
// Fails if the parent is ever widened to a loader that can see the server classpath
assertThrows(ClassNotFoundException.class, () -> isolated.loadClass(MirthContextFactory.class.getName()));
}
@Test
public void isolatedClassLoaderIsNullWithoutResources() {
MirthContextFactory contextFactory = new MirthContextFactory(new URL[0], new HashSet<>(), false);
assertNull(contextFactory.getIsolatedClassLoader());
}
}The dummy jar URL never gets read; it only exists because getIsolatedClassLoader() returns null on an empty URL array. Same AI-assisted setup as my earlier comment: the test was run against all four parents above, and a clean full build with this class added is green (665 tests, 0 failures).
|
Apologies for the oversight. I have applied the changes. I can confirm the test results. Thank you @pacmano1 for your guidance. |
cff4f7d
74d82ff to
2c85bb4
Compare
…ssLoader In Java 9 and later, passing `null` as the parent to `URLClassLoader` defaults to the Bootstrap ClassLoader, which no longer contains standard Java modules like `java.sql` or `javax.xml`. This caused isolated custom resources (such as specific JDBC drivers) to fail to deploy with ClassNotFoundExceptions. Changing the parent to `ClassLoader.getPlatformClassLoader()` restores visibility to the necessary Java platform packages while maintaining strict isolation from the Mirth server's application classpath. Additional changes in this commit: - Added `MirthContextFactoryTest` to enforce regression testing for both platform class visibility and server class isolation. - Improved Javadoc for `getIsolatedClassLoader()` to clarify platform behavior and explicitly document that the "Load Parent-First" UI option does not apply to this classloader. Signed-off-by: Paul Hristea <paul.hristea@novamap.health> Issue: OpenIntegrationEngine#338 Signed-off-by: Tony Germano <tony@germano.name>
2c85bb4 to
dab99a6
Compare
|
@paul-hristea, you do not need to keep this branch up to date with main unless there is a conflict. I think reviews are waiting on settlement of @tonygermano's issue. Everyone else has approved once already. |
Issue is settled, and I've approved. Thanks for working on this Paul! |
NicoPiel
left a comment
There was a problem hiding this comment.
non-blocking:
- ContextFactory.java: four of the new Javadoc lines have trailing whitespace ("packages) ", "as its ", "platform's copy. ", "classloader; "). The
@returnline also looks longer than the file's ~100-column wrap. - Test,
dummyJar: the relative pathbuild/tmp/...depends on the working directory. It's never read, so it does no harm, butnew URL("file:///nonexistent.jar")or aTemporaryFolderwould make that clearer. - Test: the
URLClassLoaderis never closed. This is trivial. - Test setup: it rebinds
ControllerFactorystatically, which is global state. It copies the existingJavaScriptUtilTestpattern, so that's acceptable, but it's worth knowing about if tests start running in parallel.
|
Following up on the three pre-existing issues I said I'd file separately. I tested each on main (2085b4c) first, and none turns out to be a user-facing bug, so I'm not filing them:
|

Solves #338