From c8bd40ff3ca2259739e3479b36c41a11496260dd Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 1 Oct 2026 14:54:35 +0200 Subject: [PATCH 1/2] fix: stop using deprecated HttpCommandExecutor and BiDi APIs from Selenium 4.50 Selenium 4.50 deprecates HttpCommandExecutor.client (public access), httpClientFactory, the factory-based constructors and getAddressOfRemoteServer(). It also deprecates HasBiDi.getBiDi() and maybeGetBiDi() in favour of getHandle(). - Bump Selenium to 4.50.0 and use the new HttpCommandExecutor(HttpClient, Map, URL) constructor. - Keep our own HttpClient.Factory in AppiumCommandExecutor and make getClient() public, so AppiumDriver no longer reads the fields. - Get the server URL from AppiumClientConfig / AppiumDriver.getRemoteAddress(). - Override getHandle(), and keep getBiDi()/maybeGetBiDi() for users since Selenium has no replacement for context-scoped listeners yet. Co-Authored-By: Claude Opus 5.5 --- gradle.properties | 2 +- .../io/appium/java_client/AppiumDriver.java | 33 +++++++++++++++++-- .../java_client/android/AndroidDriver.java | 4 +-- .../android/ListensToLogcatMessages.java | 4 +-- .../java_client/chromium/ChromiumDriver.java | 4 +-- .../appium/java_client/gecko/GeckoDriver.java | 4 +-- .../internal/ReflectionHelpers.java | 19 +++++++++++ .../io/appium/java_client/ios/IOSDriver.java | 4 +-- .../ios/ListensToSyslogMessages.java | 4 +-- .../io/appium/java_client/mac/Mac2Driver.java | 4 +-- .../remote/AppiumCommandExecutor.java | 22 ++++++++++--- .../java_client/safari/SafariDriver.java | 4 +-- .../java_client/windows/WindowsDriver.java | 4 +-- .../remote/AppiumCommandExecutorTest.java | 19 +++++++++++ 14 files changed, 104 insertions(+), 27 deletions(-) diff --git a/gradle.properties b/gradle.properties index d2927cceb..31468bc8b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.daemon=true -selenium.version=4.42.0 +selenium.version=4.50.0 # Please increment the value in a release appiumClient.version=10.1.1 diff --git a/src/main/java/io/appium/java_client/AppiumDriver.java b/src/main/java/io/appium/java_client/AppiumDriver.java index 44cf6ed17..87b34a17b 100644 --- a/src/main/java/io/appium/java_client/AppiumDriver.java +++ b/src/main/java/io/appium/java_client/AppiumDriver.java @@ -17,6 +17,7 @@ package io.appium.java_client; import io.appium.java_client.internal.CapabilityHelpers; +import io.appium.java_client.internal.ReflectionHelpers; import io.appium.java_client.internal.SessionHelpers; import io.appium.java_client.remote.AppiumCommandExecutor; import io.appium.java_client.remote.AppiumW3CHttpCommandCodec; @@ -34,6 +35,7 @@ import org.openqa.selenium.WebDriverException; import org.openqa.selenium.bidi.BiDi; import org.openqa.selenium.bidi.BiDiException; +import org.openqa.selenium.bidi.Handle; import org.openqa.selenium.bidi.HasBiDi; import org.openqa.selenium.remote.CapabilityType; import org.openqa.selenium.remote.CommandInfo; @@ -98,7 +100,17 @@ public AppiumDriver(HttpCommandExecutor executor, Capabilities capabilities) { super(executor, capabilities); this.executeMethod = new AppiumExecutionMethod(this); super.setErrorHandler(ERROR_HANDLER); - this.remoteAddress = executor.getAddressOfRemoteServer(); + this.remoteAddress = getServerUrl(executor); + } + + // Selenium deprecated getAddressOfRemoteServer() without a replacement, so it is only used + // for executors that are not Appium's. + @SuppressWarnings("removal") + private static URL getServerUrl(HttpCommandExecutor executor) { + if (executor instanceof AppiumCommandExecutor) { + return ((AppiumCommandExecutor) executor).getAppiumClientConfig().baseUrl(); + } + return executor.getAddressOfRemoteServer(); } public AppiumDriver(AppiumClientConfig clientConfig, Capabilities capabilities) { @@ -174,7 +186,7 @@ public AppiumDriver(URL remoteSessionAddress, String platformName, String automa setCommandExecutor(executor); this.executeMethod = new AppiumExecutionMethod(this); super.setErrorHandler(ERROR_HANDLER); - this.remoteAddress = executor.getAddressOfRemoteServer(); + this.remoteAddress = executor.getAppiumClientConfig().baseUrl(); setSessionId(sessionAddress.getId()); } @@ -265,11 +277,15 @@ public AppiumDriver markExtensionAbsence(String extName) { return this; } + // Selenium deprecated getBiDi() and maybeGetBiDi(), but has no replacement yet for listeners + // scoped to a browsing context (e.g. NATIVE_CONTEXT), so Appium keeps them for its users. + @SuppressWarnings("removal") @Override public Optional maybeGetBiDi() { return Optional.ofNullable(this.biDi); } + @SuppressWarnings("removal") @Override @NonNull public BiDi getBiDi() { @@ -299,8 +315,19 @@ public BiDi getBiDi() { return this.biDi; } + @Override + public Handle getHandle() { + return getBiDi().asHandle(); + } + protected HttpClient getHttpClient() { - return ((HttpCommandExecutor) getCommandExecutor()).client; + var executor = getCommandExecutor(); + if (executor instanceof AppiumCommandExecutor) { + return ((AppiumCommandExecutor) executor).getClient(); + } + // HttpCommandExecutor.client is becoming protected in Selenium, so read it reflectively. + return ReflectionHelpers.getPrivateFieldValue( + HttpCommandExecutor.class, executor, "client", HttpClient.class); } @Override diff --git a/src/main/java/io/appium/java_client/android/AndroidDriver.java b/src/main/java/io/appium/java_client/android/AndroidDriver.java index aa1d30159..584728fac 100644 --- a/src/main/java/io/appium/java_client/android/AndroidDriver.java +++ b/src/main/java/io/appium/java_client/android/AndroidDriver.java @@ -176,7 +176,7 @@ public AndroidDriver(HttpClient.Factory httpClientFactory, Capabilities capabili /** * Creates a new instance based on the given ClientConfig and {@code capabilities}. - * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}. + * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}. * For example: * *
@@ -200,7 +200,7 @@ public AndroidDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java b/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java
index d5051da0e..ea375d07e 100644
--- a/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java
+++ b/src/main/java/io/appium/java_client/android/ListensToLogcatMessages.java
@@ -16,10 +16,10 @@
 
 package io.appium.java_client.android;
 
+import io.appium.java_client.AppiumDriver;
 import io.appium.java_client.CommandExecutionHelper;
 import io.appium.java_client.ExecutesMethod;
 import io.appium.java_client.ws.StringWebSocketClient;
-import org.openqa.selenium.remote.HttpCommandExecutor;
 import org.openqa.selenium.remote.RemoteWebDriver;
 import org.openqa.selenium.remote.SessionId;
 
@@ -59,7 +59,7 @@ default void startLogcatBroadcast(String host) {
      */
     default void startLogcatBroadcast(String host, int port) {
         var remoteWebDriver = (RemoteWebDriver) this;
-        URL serverUrl = ((HttpCommandExecutor) remoteWebDriver.getCommandExecutor()).getAddressOfRemoteServer();
+        URL serverUrl = ((AppiumDriver) this).getRemoteAddress();
         var scheme = "https".equals(serverUrl.getProtocol()) ? "wss" : "ws";
         CommandExecutionHelper.executeScript(this, "mobile: startLogsBroadcast");
         SessionId sessionId = remoteWebDriver.getSessionId();
diff --git a/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java b/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java
index d7e34242e..9e2bb0ff6 100644
--- a/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java
+++ b/src/main/java/io/appium/java_client/chromium/ChromiumDriver.java
@@ -90,7 +90,7 @@ public ChromiumDriver(URL remoteSessionAddress, String platformName) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -113,7 +113,7 @@ public ChromiumDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/gecko/GeckoDriver.java b/src/main/java/io/appium/java_client/gecko/GeckoDriver.java
index 07cb9e3e7..73f2a3459 100644
--- a/src/main/java/io/appium/java_client/gecko/GeckoDriver.java
+++ b/src/main/java/io/appium/java_client/gecko/GeckoDriver.java
@@ -91,7 +91,7 @@ public GeckoDriver(URL remoteSessionAddress, String platformName) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -114,7 +114,7 @@ public GeckoDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java b/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java
index dd131fc65..c2559fe15 100644
--- a/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java
+++ b/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java
@@ -44,4 +44,23 @@ public static  T setPrivateFieldValue(Class cls, T target, String fieldNam
         }
         return target;
     }
+
+    /**
+     * Gets the value of a private instance field.
+     *
+     * @param cls       The target class or a superclass.
+     * @param target    Target instance.
+     * @param fieldName Target field name.
+     * @param type      The expected type of the field value.
+     * @return The field value.
+     */
+    public static  T getPrivateFieldValue(Class cls, Object target, String fieldName, Class type) {
+        try {
+            final Field f = cls.getDeclaredField(fieldName);
+            f.setAccessible(true);
+            return type.cast(f.get(target));
+        } catch (NoSuchFieldException | IllegalAccessException e) {
+            throw new WebDriverException(e);
+        }
+    }
 }
diff --git a/src/main/java/io/appium/java_client/ios/IOSDriver.java b/src/main/java/io/appium/java_client/ios/IOSDriver.java
index 0fd5cbf20..757b1b6da 100644
--- a/src/main/java/io/appium/java_client/ios/IOSDriver.java
+++ b/src/main/java/io/appium/java_client/ios/IOSDriver.java
@@ -168,7 +168,7 @@ public IOSDriver(HttpClient.Factory httpClientFactory, Capabilities capabilities
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -192,7 +192,7 @@ public IOSDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java b/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java
index 98a75158a..f3dc5481d 100644
--- a/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java
+++ b/src/main/java/io/appium/java_client/ios/ListensToSyslogMessages.java
@@ -16,10 +16,10 @@
 
 package io.appium.java_client.ios;
 
+import io.appium.java_client.AppiumDriver;
 import io.appium.java_client.CommandExecutionHelper;
 import io.appium.java_client.ExecutesMethod;
 import io.appium.java_client.ws.StringWebSocketClient;
-import org.openqa.selenium.remote.HttpCommandExecutor;
 import org.openqa.selenium.remote.RemoteWebDriver;
 import org.openqa.selenium.remote.SessionId;
 
@@ -60,7 +60,7 @@ default void startSyslogBroadcast(String host) {
      */
     default void startSyslogBroadcast(String host, int port) {
         var remoteWebDriver = (RemoteWebDriver) this;
-        URL serverUrl = ((HttpCommandExecutor) remoteWebDriver.getCommandExecutor()).getAddressOfRemoteServer();
+        URL serverUrl = ((AppiumDriver) this).getRemoteAddress();
         var scheme = "https".equals(serverUrl.getProtocol()) ? "wss" : "ws";
         CommandExecutionHelper.executeScript(this, "mobile: startLogsBroadcast");
         SessionId sessionId = remoteWebDriver.getSessionId();
diff --git a/src/main/java/io/appium/java_client/mac/Mac2Driver.java b/src/main/java/io/appium/java_client/mac/Mac2Driver.java
index 46911c314..7c21dc7fd 100644
--- a/src/main/java/io/appium/java_client/mac/Mac2Driver.java
+++ b/src/main/java/io/appium/java_client/mac/Mac2Driver.java
@@ -101,7 +101,7 @@ public Mac2Driver(URL remoteSessionAddress) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -125,7 +125,7 @@ public Mac2Driver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
index 54b590201..e44785481 100644
--- a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
+++ b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
@@ -60,6 +60,8 @@ public class AppiumCommandExecutor extends HttpCommandExecutor {
     private final Optional serviceOptional;
     @Getter
     private final AppiumClientConfig appiumClientConfig;
+    // HttpCommandExecutor no longer keeps the factory, but direct connect and BiDi need it.
+    private final Factory httpClientFactory;
 
     /**
      * Create an AppiumCommandExecutor instance.
@@ -74,13 +76,21 @@ public AppiumCommandExecutor(
             @Nullable DriverService service,
             @Nullable Factory httpClientFactory,
             AppiumClientConfig appiumClientConfig) {
-        super(additionalCommands,
-                appiumClientConfig,
-                ofNullable(httpClientFactory).orElseGet(HttpCommandExecutor::getDefaultClientFactory)
-        );
+        this(ofNullable(httpClientFactory).orElseGet(Factory::createDefault),
+                additionalCommands, service, appiumClientConfig);
+    }
+
+    private AppiumCommandExecutor(
+            Factory httpClientFactory,
+            Map additionalCommands,
+            @Nullable DriverService service,
+            AppiumClientConfig appiumClientConfig) {
+        super(httpClientFactory.createClient(appiumClientConfig), additionalCommands,
+                appiumClientConfig.baseUrl());
         serviceOptional = ofNullable(service);
 
         this.appiumClientConfig = appiumClientConfig;
+        this.httpClientFactory = httpClientFactory;
     }
 
     public AppiumCommandExecutor(Map additionalCommands, DriverService service,
@@ -148,7 +158,9 @@ public void setResponseCodec(ResponseCodec codec) {
         this.responseCodec = codec;
     }
 
-    protected HttpClient getClient() {
+    // Selenium deprecated only the public access to this field; subclasses can keep reading it.
+    @SuppressWarnings("removal")
+    public HttpClient getClient() {
         return this.client;
     }
 
diff --git a/src/main/java/io/appium/java_client/safari/SafariDriver.java b/src/main/java/io/appium/java_client/safari/SafariDriver.java
index f8f10bb01..0a5d2e764 100644
--- a/src/main/java/io/appium/java_client/safari/SafariDriver.java
+++ b/src/main/java/io/appium/java_client/safari/SafariDriver.java
@@ -98,7 +98,7 @@ public SafariDriver(URL remoteSessionAddress) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -122,7 +122,7 @@ public SafariDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/main/java/io/appium/java_client/windows/WindowsDriver.java b/src/main/java/io/appium/java_client/windows/WindowsDriver.java
index 7c6e68a7a..38c3f05e1 100644
--- a/src/main/java/io/appium/java_client/windows/WindowsDriver.java
+++ b/src/main/java/io/appium/java_client/windows/WindowsDriver.java
@@ -87,7 +87,7 @@ public WindowsDriver(URL remoteSessionAddress) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
@@ -111,7 +111,7 @@ public WindowsDriver(ClientConfig clientConfig, Capabilities capabilities) {
 
     /**
      * Creates a new instance based on the given ClientConfig and {@code capabilities}.
-     * The HTTP client is default client generated by {@link HttpCommandExecutor#getDefaultClientFactory}.
+     * The HTTP client is default client generated by {@link HttpClient.Factory#createDefault()}.
      * For example:
      *
      * 
diff --git a/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java b/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java
index b11bd6273..301df5c5d 100644
--- a/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java
+++ b/src/test/java/io/appium/java_client/remote/AppiumCommandExecutorTest.java
@@ -4,12 +4,14 @@
 import io.appium.java_client.MobileCommand;
 import org.junit.jupiter.api.Test;
 import org.openqa.selenium.SessionNotCreatedException;
+import org.openqa.selenium.remote.http.HttpClient;
 
 import java.net.MalformedURLException;
 import java.net.URL;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 class AppiumCommandExecutorTest {
@@ -36,6 +38,23 @@ void getHttpClientFactory() {
         assertNotNull(createExecutor().getHttpClientFactory());
     }
 
+    @Test
+    void keepsTheHttpClientFactoryPassedToTheConstructor() throws MalformedURLException {
+        HttpClient.Factory factory = HttpClient.Factory.createDefault();
+        AppiumCommandExecutor executor = new AppiumCommandExecutor(
+                MobileCommand.commandRepository, new URL(APPIUM_URL), factory);
+
+        assertSame(factory, executor.getHttpClientFactory());
+    }
+
+    @Test
+    void usesADefaultHttpClientFactoryWhenNoneIsPassed() throws MalformedURLException {
+        AppiumCommandExecutor executor = new AppiumCommandExecutor(
+                MobileCommand.commandRepository, new URL(APPIUM_URL), (HttpClient.Factory) null);
+
+        assertNotNull(executor.getHttpClientFactory());
+    }
+
     @Test
     void overrideServerUrl() throws MalformedURLException {
         assertDoesNotThrow(() -> createExecutor().overrideServerUrl(new URL("https://203.0.113.1/wd/hub")));

From 156e7807bbcc7c5c0ad6b1101ef6497cdeb7af30 Mon Sep 17 00:00:00 2001
From: Diego Molina 
Date: Thu, 1 Oct 2026 15:05:36 +0200
Subject: [PATCH 2/2] fix: stop using reflection to read or replace the
 HttpCommandExecutor client

- Direct connect now switches the target of a SwitchableHttpClient that
  AppiumCommandExecutor passes to Selenium, instead of replacing the
  final client field by reflection. The previous client is closed.
- For executors that are not AppiumCommandExecutor, AppiumDriver creates
  its own HTTP client for log websockets (as BiDi already does) and
  closes it on quit.

Co-Authored-By: Claude Opus 5.5 
---
 .../io/appium/java_client/AppiumDriver.java   | 28 +++++--
 .../internal/ReflectionHelpers.java           | 19 -----
 .../remote/AppiumCommandExecutor.java         | 29 ++++---
 .../remote/SwitchableHttpClient.java          | 73 +++++++++++++++++
 .../remote/SwitchableHttpClientTest.java      | 79 +++++++++++++++++++
 5 files changed, 194 insertions(+), 34 deletions(-)
 create mode 100644 src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java
 create mode 100644 src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java

diff --git a/src/main/java/io/appium/java_client/AppiumDriver.java b/src/main/java/io/appium/java_client/AppiumDriver.java
index 87b34a17b..176dce90c 100644
--- a/src/main/java/io/appium/java_client/AppiumDriver.java
+++ b/src/main/java/io/appium/java_client/AppiumDriver.java
@@ -17,7 +17,6 @@
 package io.appium.java_client;
 
 import io.appium.java_client.internal.CapabilityHelpers;
-import io.appium.java_client.internal.ReflectionHelpers;
 import io.appium.java_client.internal.SessionHelpers;
 import io.appium.java_client.remote.AppiumCommandExecutor;
 import io.appium.java_client.remote.AppiumW3CHttpCommandCodec;
@@ -87,6 +86,8 @@ public class AppiumDriver extends RemoteWebDriver implements
     private URI biDiUri;
     private BiDi biDi;
     private boolean wasBiDiRequested = false;
+    // Used for log websockets when the command executor is not an AppiumCommandExecutor.
+    private HttpClient ownHttpClient;
 
     /**
      * Creates a new instance based on command {@code executor} and {@code capabilities}.
@@ -320,14 +321,31 @@ public Handle getHandle() {
         return getBiDi().asHandle();
     }
 
-    protected HttpClient getHttpClient() {
+    protected synchronized HttpClient getHttpClient() {
         var executor = getCommandExecutor();
         if (executor instanceof AppiumCommandExecutor) {
             return ((AppiumCommandExecutor) executor).getClient();
         }
-        // HttpCommandExecutor.client is becoming protected in Selenium, so read it reflectively.
-        return ReflectionHelpers.getPrivateFieldValue(
-                HttpCommandExecutor.class, executor, "client", HttpClient.class);
+        // Selenium does not expose the client of other executors, so create one, as BiDi does.
+        // Keep a strong reference: StringWebSocketClient only holds the client weakly.
+        if (ownHttpClient == null) {
+            ownHttpClient = HttpClient.Factory.createDefault().createClient(remoteAddress);
+        }
+        return ownHttpClient;
+    }
+
+    @Override
+    public void quit() {
+        try {
+            super.quit();
+        } finally {
+            synchronized (this) {
+                if (ownHttpClient != null) {
+                    ownHttpClient.close();
+                    ownHttpClient = null;
+                }
+            }
+        }
     }
 
     @Override
diff --git a/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java b/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java
index c2559fe15..dd131fc65 100644
--- a/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java
+++ b/src/main/java/io/appium/java_client/internal/ReflectionHelpers.java
@@ -44,23 +44,4 @@ public static  T setPrivateFieldValue(Class cls, T target, String fieldNam
         }
         return target;
     }
-
-    /**
-     * Gets the value of a private instance field.
-     *
-     * @param cls       The target class or a superclass.
-     * @param target    Target instance.
-     * @param fieldName Target field name.
-     * @param type      The expected type of the field value.
-     * @return The field value.
-     */
-    public static  T getPrivateFieldValue(Class cls, Object target, String fieldName, Class type) {
-        try {
-            final Field f = cls.getDeclaredField(fieldName);
-            f.setAccessible(true);
-            return type.cast(f.get(target));
-        } catch (NoSuchFieldException | IllegalAccessException e) {
-            throw new WebDriverException(e);
-        }
-    }
 }
diff --git a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
index e44785481..125436cf9 100644
--- a/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
+++ b/src/main/java/io/appium/java_client/remote/AppiumCommandExecutor.java
@@ -62,6 +62,8 @@ public class AppiumCommandExecutor extends HttpCommandExecutor {
     private final AppiumClientConfig appiumClientConfig;
     // HttpCommandExecutor no longer keeps the factory, but direct connect and BiDi need it.
     private final Factory httpClientFactory;
+    // Lets direct connect point to a new server URL without changing HttpCommandExecutor.client.
+    private final SwitchableHttpClient switchableClient;
 
     /**
      * Create an AppiumCommandExecutor instance.
@@ -85,12 +87,22 @@ private AppiumCommandExecutor(
             Map additionalCommands,
             @Nullable DriverService service,
             AppiumClientConfig appiumClientConfig) {
-        super(httpClientFactory.createClient(appiumClientConfig), additionalCommands,
-                appiumClientConfig.baseUrl());
+        this(new SwitchableHttpClient(httpClientFactory.createClient(appiumClientConfig)),
+                httpClientFactory, additionalCommands, service, appiumClientConfig);
+    }
+
+    private AppiumCommandExecutor(
+            SwitchableHttpClient switchableClient,
+            Factory httpClientFactory,
+            Map additionalCommands,
+            @Nullable DriverService service,
+            AppiumClientConfig appiumClientConfig) {
+        super(switchableClient, additionalCommands, appiumClientConfig.baseUrl());
         serviceOptional = ofNullable(service);
 
         this.appiumClientConfig = appiumClientConfig;
         this.httpClientFactory = httpClientFactory;
+        this.switchableClient = switchableClient;
     }
 
     public AppiumCommandExecutor(Map additionalCommands, DriverService service,
@@ -158,16 +170,14 @@ public void setResponseCodec(ResponseCodec codec) {
         this.responseCodec = codec;
     }
 
-    // Selenium deprecated only the public access to this field; subclasses can keep reading it.
-    @SuppressWarnings("removal")
     public HttpClient getClient() {
-        return this.client;
+        return this.switchableClient;
     }
 
     /**
-     * Override the http client in the HttpCommandExecutor class with a new http client instance with the given URL.
-     * It uses the same http client factory and client config for the new http client instance
-     * if the constructor got them.
+     * Switch the http client of this executor to a new http client instance with the given URL.
+     * It uses the same http client factory and client config for the new http client instance.
+     * The previous http client is closed.
      *
      * @param serverUrl URL to use for subsequent HTTP requests. Before switching clients, the host is
      *                  resolved and the override is refused if any resolved address is loopback,
@@ -178,8 +188,7 @@ public HttpClient getClient() {
      */
     protected void overrideServerUrl(URL serverUrl) {
         DirectConnectUrlSafety.requireSafeOverrideTarget(serverUrl);
-        HttpClient newClient = getHttpClientFactory().createClient(appiumClientConfig.baseUrl(serverUrl));
-        setPrivateFieldValue(HttpCommandExecutor.class, "client", newClient);
+        switchableClient.switchTo(getHttpClientFactory().createClient(appiumClientConfig.baseUrl(serverUrl)));
     }
 
     private Response createSession(Command command) throws IOException {
diff --git a/src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java b/src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java
new file mode 100644
index 000000000..7d64500fb
--- /dev/null
+++ b/src/main/java/io/appium/java_client/remote/SwitchableHttpClient.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.remote;
+
+import org.jspecify.annotations.NullMarked;
+import org.openqa.selenium.remote.http.HttpClient;
+import org.openqa.selenium.remote.http.HttpRequest;
+import org.openqa.selenium.remote.http.HttpResponse;
+import org.openqa.selenium.remote.http.WebSocket;
+
+import java.util.concurrent.CompletableFuture;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * An {@link HttpClient} that forwards every call to another client, which can be replaced.
+ * Direct connect uses it to point the executor to a new server URL without touching the
+ * client field inside {@link org.openqa.selenium.remote.HttpCommandExecutor}.
+ */
+@NullMarked
+final class SwitchableHttpClient implements HttpClient {
+
+    private volatile HttpClient delegate;
+
+    SwitchableHttpClient(HttpClient delegate) {
+        this.delegate = requireNonNull(delegate);
+    }
+
+    /**
+     * Sends all later calls to the given client and closes the previous one.
+     *
+     * @param newDelegate the client to use from now on
+     */
+    void switchTo(HttpClient newDelegate) {
+        HttpClient previous = this.delegate;
+        this.delegate = requireNonNull(newDelegate);
+        previous.close();
+    }
+
+    @Override
+    public HttpResponse execute(HttpRequest req) {
+        return delegate.execute(req);
+    }
+
+    @Override
+    public CompletableFuture executeAsync(HttpRequest req) {
+        return delegate.executeAsync(req);
+    }
+
+    @Override
+    public WebSocket openSocket(HttpRequest request, WebSocket.Listener listener) {
+        return delegate.openSocket(request, listener);
+    }
+
+    @Override
+    public void close() {
+        delegate.close();
+    }
+}
diff --git a/src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java b/src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java
new file mode 100644
index 000000000..1376e959e
--- /dev/null
+++ b/src/test/java/io/appium/java_client/remote/SwitchableHttpClientTest.java
@@ -0,0 +1,79 @@
+package io.appium.java_client.remote;
+
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.remote.http.HttpClient;
+import org.openqa.selenium.remote.http.HttpMethod;
+import org.openqa.selenium.remote.http.HttpRequest;
+import org.openqa.selenium.remote.http.HttpResponse;
+import org.openqa.selenium.remote.http.WebSocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SwitchableHttpClientTest {
+
+    private static final HttpRequest REQUEST = new HttpRequest(HttpMethod.GET, "/status");
+
+    private static class FakeHttpClient implements HttpClient {
+        private final int status;
+        private boolean closed;
+
+        FakeHttpClient(int status) {
+            this.status = status;
+        }
+
+        @Override
+        public HttpResponse execute(HttpRequest req) {
+            return new HttpResponse().setStatus(status);
+        }
+
+        @Override
+        public WebSocket openSocket(HttpRequest request, WebSocket.Listener listener) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+        }
+    }
+
+    @Test
+    void sendsRequestsToTheCurrentClient() {
+        var client = new SwitchableHttpClient(new FakeHttpClient(200));
+
+        assertEquals(200, client.execute(REQUEST).getStatus());
+    }
+
+    @Test
+    void sendsRequestsToTheNewClientAfterSwitching() {
+        var client = new SwitchableHttpClient(new FakeHttpClient(200));
+
+        client.switchTo(new FakeHttpClient(201));
+
+        assertEquals(201, client.execute(REQUEST).getStatus());
+    }
+
+    @Test
+    void closesThePreviousClientWhenSwitching() {
+        var previous = new FakeHttpClient(200);
+        var next = new FakeHttpClient(201);
+        var client = new SwitchableHttpClient(previous);
+
+        client.switchTo(next);
+
+        assertTrue(previous.closed);
+        assertFalse(next.closed);
+    }
+
+    @Test
+    void closesTheCurrentClient() {
+        var current = new FakeHttpClient(200);
+        var client = new SwitchableHttpClient(current);
+
+        client.close();
+
+        assertTrue(current.closed);
+    }
+}