Skip to content

Commit 0f046fa

Browse files
committed
fix: Do not require SQLException class in SQL hooks
The agent was encountering a NoClassDefFoundError for java.sql.SQLException in some environments (e.g., Oracle UCP) due to class loading issues. - Removed direct import of java.sql.SQLException in SqlQuery.java. - Changed catch blocks in getDbName methods to catch Throwable instead of SQLException to broaden exception handling and prevent crashes when SQLException is not directly available. - Added regression test SqlQuerySQLExceptionAvailabilityTest to reproduce the environment where SQLException is missing and verify the fix.
1 parent 8ec4c61 commit 0f046fa

File tree

2 files changed

+132
-3
lines changed

2 files changed

+132
-3
lines changed

agent/src/main/java/com/appland/appmap/process/hooks/SqlQuery.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import java.sql.Connection;
44
import java.sql.DatabaseMetaData;
5-
import java.sql.SQLException;
65
import java.sql.Statement;
76

87
import com.appland.appmap.output.v1.Event;
@@ -55,7 +54,7 @@ private static String getDbName(Connection c) {
5554
}
5655

5756
dbname = metadata.getDatabaseProductName();
58-
} catch (SQLException e) {
57+
} catch (Throwable e) {
5958
Logger.println("WARNING, failed to get database name");
6059
e.printStackTrace(System.err);
6160
}
@@ -74,7 +73,7 @@ private static String getDbName(Statement s) {
7473
}
7574

7675
dbname = getDbName(s.getConnection());
77-
} catch (SQLException e) {
76+
} catch (Throwable e) {
7877
Logger.println("WARNING, failed to get statement's connection");
7978
e.printStackTrace(System.err);
8079
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.appland.appmap.process.hooks;
2+
3+
import org.junit.jupiter.api.Test;
4+
import java.io.ByteArrayOutputStream;
5+
import java.io.IOException;
6+
import java.io.InputStream;
7+
import java.lang.reflect.Method;
8+
import java.sql.Connection;
9+
10+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
11+
import static org.mockito.Mockito.mock;
12+
13+
/**
14+
* Regression test for a {@link NoClassDefFoundError} involving {@link java.sql.SQLException}.
15+
* <p>
16+
* In certain environments (e.g., specific configurations of Oracle UCP or custom container classloaders),
17+
* {@code java.sql.SQLException} might not be visible to the classloader responsible for loading
18+
* {@code com.appland.appmap.process.hooks.SqlQuery}. This can lead to a crash when the agent attempts
19+
* to handle SQL events.
20+
* <p>
21+
* The crash manifests as:
22+
* <pre>
23+
* Caused by: com.example.operation.flow.FlowException: java/sql/SQLException
24+
* ...
25+
* Caused by: java.lang.NoClassDefFoundError: java/sql/SQLException
26+
* at com.appland.appmap.process.hooks.SqlQuery.getDbName(SqlQuery.java:76)
27+
* at com.appland.appmap.process.hooks.SqlQuery.recordSql(SqlQuery.java:89)
28+
* at com.appland.appmap.process.hooks.SqlQuery.executeQuery(SqlQuery.java:172)
29+
* </pre>
30+
* <p>
31+
* This test reproduces the environment by using a custom {@link ClassLoader} that explicitly
32+
* throws {@link ClassNotFoundException} when {@code java.sql.SQLException} is requested.
33+
* It verifies that {@code SqlQuery} can be loaded and executed without triggering the error.
34+
*/
35+
public class SqlQuerySQLExceptionAvailabilityTest {
36+
37+
@Test
38+
public void testSqlQueryResilienceToMissingSQLException() throws Exception {
39+
// 1. Create a RestrictedClassLoader that hides java.sql.SQLException
40+
ClassLoader restrictedLoader = new RestrictedClassLoader(this.getClass().getClassLoader());
41+
42+
// 2. Load the SqlQuery class using the restricted loader.
43+
// This forces the verifier to check dependencies of SqlQuery using our restricted loader.
44+
// If SqlQuery explicitly catches or references SQLException in a way that requires resolution,
45+
// this (or the method invocation below) should fail.
46+
String sqlQueryClassName = "com.appland.appmap.process.hooks.SqlQuery";
47+
Class<?> sqlQueryClass = restrictedLoader.loadClass(sqlQueryClassName);
48+
49+
// 3. Invoke a method that triggers the problematic code path (getDbName).
50+
// We choose recordSql(Event, Connection, String) which calls getDbName(Connection).
51+
Method recordSqlMethod = sqlQueryClass.getMethod("recordSql",
52+
com.appland.appmap.output.v1.Event.class,
53+
java.sql.Connection.class,
54+
String.class
55+
);
56+
57+
// Prepare arguments
58+
com.appland.appmap.output.v1.Event mockEvent = mock(com.appland.appmap.output.v1.Event.class);
59+
try (Connection mockConnection = mock(Connection.class)) {
60+
// Ensure getMetaData() throws an exception (simulating a failure), but we catch Throwable now.
61+
// Note: We can't easily throw SQLException here because it's checked, and we're in a context
62+
// where we claim it doesn't exist? Actually, the test code here runs in the normal classloader,
63+
// so we CAN throw it. The question is how SqlQuery handles it.
64+
// However, if SqlQuery references SQLException in its bytecode, loading/verification fails before execution.
65+
66+
// Let's just run it. The mere act of loading and verifying the method is the primary test.
67+
// Executing it ensures JIT/runtime verification passes too.
68+
69+
assertDoesNotThrow(() -> {
70+
recordSqlMethod.invoke(null, mockEvent, mockConnection, "SELECT 1");
71+
}, "SqlQuery should not fail even if java.sql.SQLException is missing");
72+
}
73+
}
74+
75+
/**
76+
* A ClassLoader that throws ClassNotFoundException for java.sql.SQLException
77+
* and forces re-definition of SqlQuery to ensure it's loaded by this loader.
78+
*/
79+
private static class RestrictedClassLoader extends ClassLoader {
80+
81+
public RestrictedClassLoader(ClassLoader parent) {
82+
super(parent);
83+
}
84+
85+
@Override
86+
public Class<?> loadClass(String name) throws ClassNotFoundException {
87+
String forbiddenClassName = "java.sql.SQLException";
88+
if (forbiddenClassName.equals(name)) {
89+
throw new ClassNotFoundException("Simulated missing class: " + name);
90+
}
91+
92+
// If it's the target class, we want to define it ourselves to ensure
93+
// this classloader (and its restrictions) is used for verification.
94+
String targetClassName = "com.appland.appmap.process.hooks.SqlQuery";
95+
if (targetClassName.equals(name)) {
96+
// Check if already loaded
97+
Class<?> loaded = findLoadedClass(name);
98+
if (loaded != null) {
99+
return loaded;
100+
}
101+
102+
try {
103+
byte[] bytes = loadClassBytes(name);
104+
return defineClass(name, bytes, 0, bytes.length);
105+
} catch (IOException e) {
106+
throw new ClassNotFoundException("Failed to read bytes for " + name, e);
107+
}
108+
}
109+
110+
// For everything else, delegate to parent
111+
return super.loadClass(name);
112+
}
113+
114+
private byte[] loadClassBytes(String className) throws IOException {
115+
String resourceName = "/" + className.replace('.', '/') + ".class";
116+
try (InputStream is = getClass().getResourceAsStream(resourceName)) {
117+
if (is == null) {
118+
throw new IOException("Resource not found: " + resourceName);
119+
}
120+
ByteArrayOutputStream stream = new ByteArrayOutputStream();
121+
byte[] buffer = new byte[4096];
122+
int bytesRead;
123+
while ((bytesRead = is.read(buffer)) != -1) {
124+
stream.write(buffer, 0, bytesRead);
125+
}
126+
return stream.toByteArray();
127+
}
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)