From 11e0db5acbf22b0c218ace543891af2c7baa609e Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Mon, 7 Sep 2026 21:26:30 +0800 Subject: [PATCH] fix(h2): detect remote server version via H2VERSION() --- agents/drivers/h2/build.gradle | 1 + .../main/java/com/dbx/agent/h2/H2Agent.java | 24 +++- .../com/dbx/agent/h2/H2AgentProcessTest.java | 118 ++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/agents/drivers/h2/build.gradle b/agents/drivers/h2/build.gradle index 01248b4295..bba215e815 100644 --- a/agents/drivers/h2/build.gradle +++ b/agents/drivers/h2/build.gradle @@ -36,5 +36,6 @@ tasks.named('test') { dependsOn tasks.named('shadowJar') doFirst { systemProperty 'dbx.h2.agent.jar', tasks.named('shadowJar').get().archiveFile.get().asFile.absolutePath + systemProperty 'dbx.h2.v1.driver.jar', configurations.h2V1.asPath } } diff --git a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java index 5e8e8df9d5..e3c64be7a8 100644 --- a/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java +++ b/agents/drivers/h2/src/main/java/com/dbx/agent/h2/H2Agent.java @@ -92,7 +92,29 @@ public class H2Agent extends AbstractJdbcAgent { @Override protected void afterConnect(ConnectParams params, Connection connection) { databaseName = params.getDatabase(); - databaseMajorVersion = unchecked(() -> connection.getMetaData().getDatabaseMajorVersion()); + databaseMajorVersion = detectDatabaseMajorVersion(connection); + } + + // For a remote (TCP/SSL) connection, JDBC metadata such as + // getDatabaseMajorVersion() reflects the *loaded driver's* own version, not + // the server's, whenever the driver profile could not be auto-detected from + // a local database file (see H2FileFormatDetector) and fell back to the + // newest bundled driver. H2VERSION() is evaluated by the server itself, so + // it reports the real engine version regardless of which driver connected. + private static int detectDatabaseMajorVersion(Connection connection) { + try (java.sql.Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT H2VERSION()")) { + if (resultSet.next()) { + String version = resultSet.getString(1); + int dot = version == null ? -1 : version.indexOf('.'); + if (dot > 0) { + return Integer.parseInt(version.substring(0, dot)); + } + } + } catch (SQLException | NumberFormatException ignored) { + // Fall back to JDBC driver metadata below. + } + return unchecked(() -> connection.getMetaData().getDatabaseMajorVersion()); } H2DriverVersion driverVersion() { diff --git a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java index 640d183844..ea3307e135 100644 --- a/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java +++ b/agents/drivers/h2/src/test/java/com/dbx/agent/h2/H2AgentProcessTest.java @@ -12,6 +12,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; @@ -99,6 +100,123 @@ class H2AgentProcessTest { } } + // Regression test for a legacy (H2 1.x) *remote* server: the file-format + // auto-detector (H2FileFormatDetector) cannot inspect a TCP database's + // storage file, so driver selection falls back to the newest bundled + // driver (H2 2.4.240). That mismatched driver's own JDBC metadata + // (getDatabaseMajorVersion()) then reports its own version rather than the + // server's, which used to make the agent send an H2 2.x-only + // INFORMATION_SCHEMA.ROUTINES query to a database that doesn't have that + // catalog table, failing list_objects entirely (dbx#8356). + @Test + @Timeout(45) + void listObjectsAutoDetectsLegacyServerOverTcp() throws Exception { + Path h2V1Jar = Path.of(System.getProperty("dbx.h2.v1.driver.jar")); + Assertions.assertTrue(Files.isRegularFile(h2V1Jar), () -> "Missing bundled H2 1.4.200 driver jar: " + h2V1Jar); + Path agentJar = Path.of(System.getProperty("dbx.h2.agent.jar")); + Assertions.assertTrue(Files.isRegularFile(agentJar), () -> "Missing shaded H2 Agent: " + agentJar); + + try (LegacyTcpServer server = new LegacyTcpServer(h2V1Jar)) { + try (RpcProcess rpc = new RpcProcess(agentJar)) { + String sessionId = "session-legacy-tcp"; + JsonObject params = sessionParams(sessionId); + params.addProperty("host", "127.0.0.1"); + params.addProperty("port", server.port()); + // A fresh, unique database per run: the TCP server process's + // working directory (and any .mv.db file it writes) persists + // across separate test invocations, so a fixed name would leak + // schema objects between runs. + params.addProperty("database", "./legacy-tcp-test-" + java.util.UUID.randomUUID()); + params.addProperty("username", "sa"); + params.addProperty("password", ""); + params.addProperty("connection_string", ""); + params.addProperty("ssl", false); + // driver_profile intentionally omitted: exercise the "auto" path. + + JsonObject connected = rpc.result(rpc.request("open_session", params)); + Assertions.assertTrue(connected.get("ok").getAsBoolean()); + + rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE TABLE LEGACY_TABLE (ID INT PRIMARY KEY)"))); + rpc.result(rpc.request("execute_query", queryParams(sessionId, "CREATE ALIAS LEGACY_FUNC FOR \"java.lang.Integer.reverse\""))); + + JsonArray objects = rpc.resultArray(rpc.request("list_objects", metadataParams(sessionId, "PUBLIC"))); + List names = objects.asList().stream().map(value -> value.getAsJsonObject().get("name").getAsString()).toList(); + Assertions.assertTrue(names.contains("LEGACY_TABLE"), names.toString()); + Assertions.assertTrue(names.contains("LEGACY_FUNC"), names.toString()); + + Assertions.assertTrue(rpc.result(rpc.request("close_session", sessionParams(sessionId))).get("ok").getAsBoolean()); + } + } + } + + private static final class LegacyTcpServer implements AutoCloseable { + private final Process process; + private final int port; + + private LegacyTcpServer(Path h2V1Jar) throws Exception { + this.port = findFreePort(); + Path java = Path.of(System.getProperty("java.home"), "bin", "java"); + process = new ProcessBuilder( + java.toString(), "-cp", h2V1Jar.toString(), "org.h2.tools.Server", + "-tcp", "-tcpPort", Integer.toString(port), "-ifNotExists" + ).redirectErrorStream(true).start(); + // -tcpDaemon is intentionally omitted: it marks the *listener* thread + // as a daemon for embedding inside a host JVM that outlives it. Here + // the server *is* the whole process, so a daemon-only listener thread + // would let the JVM exit (dropping sessions) the moment main() returns. + drainOutput(); + waitUntilListening(); + } + + private void drainOutput() { + Thread thread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + while (reader.readLine() != null) { + // Discard: only draining to prevent the pipe buffer from filling. + } + } catch (java.io.IOException ignored) { + } + }, "legacy-h2-server-stdout"); + thread.setDaemon(true); + thread.start(); + } + + private int port() { + return port; + } + + private static int findFreePort() throws Exception { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilListening() throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (System.nanoTime() < deadline) { + try (java.net.Socket probe = new java.net.Socket()) { + probe.connect(new java.net.InetSocketAddress("127.0.0.1", port), 200); + return; + } catch (java.io.IOException notReady) { + Thread.sleep(100); + } + } + throw new AssertionError("Legacy H2 TCP server never started listening on port " + port); + } + + @Override + public void close() { + process.destroy(); + try { + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + private static JsonObject connectParams(String sessionId, String profile) { JsonObject params = sessionParams(sessionId); params.addProperty("host", "");