From c936caa104215bcfa1ab082d2a611ab8693f996b Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 12:17:48 -0700 Subject: [PATCH 1/8] Lazy-initialize SerialPort in transport classes --- .../client/SerialPortClientTransport.java | 94 ++++++++++++++----- .../server/SerialPortServerTransport.java | 78 ++++++++++----- 2 files changed, 124 insertions(+), 48 deletions(-) diff --git a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java index a7b8c24..2e75f7c 100644 --- a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java +++ b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java @@ -5,6 +5,8 @@ import com.digitalpetri.modbus.ModbusRtuResponseFrameParser.Accumulated; import com.digitalpetri.modbus.ModbusRtuResponseFrameParser.ParserState; import com.digitalpetri.modbus.client.ModbusRtuClientTransport; +import com.digitalpetri.modbus.exceptions.ModbusConnectException; +import com.digitalpetri.modbus.exceptions.ModbusException; import com.digitalpetri.modbus.internal.util.ExecutionQueue; import com.digitalpetri.modbus.serial.SerialPortTransportConfig; import com.fazecast.jSerialComm.SerialPort; @@ -27,67 +29,97 @@ public class SerialPortClientTransport implements ModbusRtuClientTransport { private final ExecutionQueue executionQueue; - private final SerialPort serialPort; + private volatile SerialPort serialPort; private final SerialPortTransportConfig config; public SerialPortClientTransport(SerialPortTransportConfig config) { this.config = config; - serialPort = SerialPort.getCommPort(config.serialPort()); - - serialPort.setComPortParameters( - config.baudRate(), - config.dataBits(), - config.stopBits(), - config.parity(), - config.rs485Mode()); - executionQueue = new ExecutionQueue(config.executor()); } /** * Return the underlying {@link SerialPort} used by this transport. * + *

The serial port is lazily instantiated on first access. + * * @return the configured {@link SerialPort} instance. + * @throws ModbusException if the serial port could not be created. */ - public SerialPort getSerialPort() { - return serialPort; + public SerialPort getSerialPort() throws ModbusException { + SerialPort sp = this.serialPort; + if (sp == null) { + synchronized (this) { + sp = this.serialPort; + if (sp == null) { + try { + sp = SerialPort.getCommPort(config.serialPort()); + sp.setComPortParameters( + config.baudRate(), + config.dataBits(), + config.stopBits(), + config.parity(), + config.rs485Mode()); + this.serialPort = sp; + } catch (Exception e) { + throw new ModbusException( + "failed to get comm port '%s'".formatted(config.serialPort()), e); + } + } + } + } + return sp; } @Override public synchronized CompletableFuture connect() { - if (serialPort.isOpen()) { + SerialPort sp; + try { + sp = getSerialPort(); + } catch (ModbusException e) { + return CompletableFuture.failedFuture( + new ModbusConnectException(e.getMessage(), e.getCause())); + } + + if (sp.isOpen()) { return CompletableFuture.completedFuture(null); } else { - if (serialPort.openPort()) { + if (sp.openPort()) { frameParser.reset(); // note: no-op if already added from previous connect() - serialPort.addDataListener(new ModbusRtuDataListener()); + sp.addDataListener(new ModbusRtuDataListener()); return CompletableFuture.completedFuture(null); } else { return CompletableFuture.failedFuture( - new Exception( + new ModbusConnectException( "failed to open port '%s', lastErrorCode=%d" - .formatted(config.serialPort(), serialPort.getLastErrorCode()))); + .formatted(config.serialPort(), sp.getLastErrorCode()))); } } } + /** + * {@inheritDoc} + * + *

The returned {@link CompletionStage} may complete exceptionally with a {@link + * ModbusException} if the serial port could not be closed. + */ @Override public synchronized CompletableFuture disconnect() { - if (serialPort.isOpen()) { - if (serialPort.closePort()) { + SerialPort sp = this.serialPort; + if (sp != null && sp.isOpen()) { + if (sp.closePort()) { frameParser.reset(); return CompletableFuture.completedFuture(null); } else { return CompletableFuture.failedFuture( - new Exception( + new ModbusException( "failed to close port '%s', lastErrorCode=%d" - .formatted(config.serialPort(), serialPort.getLastErrorCode()))); + .formatted(config.serialPort(), sp.getLastErrorCode()))); } } else { return CompletableFuture.completedFuture(null); @@ -96,11 +128,23 @@ public synchronized CompletableFuture disconnect() { @Override public boolean isConnected() { - return serialPort.isOpen(); + SerialPort sp = this.serialPort; + return sp != null && sp.isOpen(); } + /** + * {@inheritDoc} + * + *

The returned {@link CompletionStage} may complete exceptionally with a {@link + * ModbusException} if the transport is not connected or if writing to the serial port fails. + */ @Override public CompletionStage send(ModbusRtuFrame frame) { + SerialPort sp = this.serialPort; + if (sp == null) { + return CompletableFuture.failedFuture(new ModbusException("not connected")); + } + ByteBuffer buffer = ByteBuffer.allocate(256); try { @@ -114,10 +158,10 @@ public CompletionStage send(ModbusRtuFrame frame) { int totalWritten = 0; while (totalWritten < data.length) { - int written = serialPort.writeBytes(data, data.length - totalWritten, totalWritten); + int written = sp.writeBytes(data, data.length - totalWritten, totalWritten); if (written == -1) { - int errorCode = serialPort.getLastErrorCode(); - throw new Exception( + int errorCode = sp.getLastErrorCode(); + throw new ModbusException( "failed to write to port '%s', lastErrorCode=%d" .formatted(config.serialPort(), errorCode)); } diff --git a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java index b882035..a356c6f 100644 --- a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java +++ b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java @@ -4,6 +4,8 @@ import com.digitalpetri.modbus.ModbusRtuRequestFrameParser; import com.digitalpetri.modbus.ModbusRtuRequestFrameParser.Accumulated; import com.digitalpetri.modbus.ModbusRtuRequestFrameParser.ParserState; +import com.digitalpetri.modbus.exceptions.ModbusConnectException; +import com.digitalpetri.modbus.exceptions.ModbusException; import com.digitalpetri.modbus.exceptions.UnknownUnitIdException; import com.digitalpetri.modbus.internal.util.ExecutionQueue; import com.digitalpetri.modbus.serial.SerialPortTransportConfig; @@ -35,66 +37,96 @@ public class SerialPortServerTransport implements ModbusRtuServerTransport { private final ExecutionQueue executionQueue; - private final SerialPort serialPort; + private volatile SerialPort serialPort; private final SerialPortTransportConfig config; public SerialPortServerTransport(SerialPortTransportConfig config) { this.config = config; - serialPort = SerialPort.getCommPort(config.serialPort()); - - serialPort.setComPortParameters( - config.baudRate(), - config.dataBits(), - config.stopBits(), - config.parity(), - config.rs485Mode()); - executionQueue = new ExecutionQueue(config.executor()); } /** * Return the underlying {@link SerialPort} used by this transport. * + *

The serial port is lazily instantiated on first access. + * * @return the configured {@link SerialPort} instance. + * @throws ModbusException if the serial port could not be created. */ - public SerialPort getSerialPort() { - return serialPort; + public SerialPort getSerialPort() throws ModbusException { + SerialPort sp = this.serialPort; + if (sp == null) { + synchronized (this) { + sp = this.serialPort; + if (sp == null) { + try { + sp = SerialPort.getCommPort(config.serialPort()); + sp.setComPortParameters( + config.baudRate(), + config.dataBits(), + config.stopBits(), + config.parity(), + config.rs485Mode()); + this.serialPort = sp; + } catch (Exception e) { + throw new ModbusException( + "failed to get comm port '%s'".formatted(config.serialPort()), e); + } + } + } + } + return sp; } @Override public CompletionStage bind() { - if (serialPort.isOpen()) { + SerialPort sp; + try { + sp = getSerialPort(); + } catch (ModbusException e) { + return CompletableFuture.failedFuture( + new ModbusConnectException(e.getMessage(), e.getCause())); + } + + if (sp.isOpen()) { return CompletableFuture.completedFuture(null); } else { - if (serialPort.openPort()) { + if (sp.openPort()) { frameParser.reset(); - serialPort.addDataListener(new ModbusRtuDataListener()); + sp.addDataListener(new ModbusRtuDataListener()); return CompletableFuture.completedFuture(null); } else { return CompletableFuture.failedFuture( - new Exception( + new ModbusConnectException( "failed to open port '%s', lastErrorCode=%d" - .formatted(config.serialPort(), serialPort.getLastErrorCode()))); + .formatted(config.serialPort(), sp.getLastErrorCode()))); } } } + /** + * {@inheritDoc} + * + *

The returned {@link CompletionStage} may complete exceptionally with a {@link + * ModbusException} if the serial port could not be closed. + */ @Override public CompletionStage unbind() { - if (serialPort.isOpen()) { - if (serialPort.closePort()) { + SerialPort sp = this.serialPort; + if (sp != null && sp.isOpen()) { + if (sp.closePort()) { frameParser.reset(); return CompletableFuture.completedFuture(null); } else { return CompletableFuture.failedFuture( - new Exception( + new ModbusException( "failed to close port '%s', lastErrorCode=%d" - .formatted(config.serialPort(), serialPort.getLastErrorCode()))); + .formatted(config.serialPort(), sp.getLastErrorCode()))); } } else { return CompletableFuture.completedFuture(null); @@ -157,10 +189,10 @@ private void onFrameReceived(ModbusRtuFrame requestFrame) { pdu.get(data, 1, pdu.remaining()); crc.get(data, data.length - 2, crc.remaining()); + SerialPort sp = SerialPortServerTransport.this.serialPort; int totalWritten = 0; while (totalWritten < data.length) { - int written = - serialPort.writeBytes(data, data.length - totalWritten, totalWritten); + int written = sp.writeBytes(data, data.length - totalWritten, totalWritten); if (written == -1) { logger.error("Error writing frame to serial port"); From d9f1de46f88841f3a45577f92db12b928c0747fb Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 12:19:02 -0700 Subject: [PATCH 2/8] Add tests for SerialPort behavior and exception handling --- .../SerialPortGetCommPortBehaviorTest.java | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java diff --git a/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java b/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java new file mode 100644 index 0000000..202d5ec --- /dev/null +++ b/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java @@ -0,0 +1,136 @@ +package com.digitalpetri.modbus.serial; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.digitalpetri.modbus.exceptions.ModbusConnectException; +import com.digitalpetri.modbus.exceptions.ModbusException; +import com.digitalpetri.modbus.serial.client.SerialPortClientTransport; +import com.digitalpetri.modbus.serial.server.SerialPortServerTransport; +import com.fazecast.jSerialComm.SerialPort; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +/** + * Tests documenting the behavior of {@link SerialPort#getCommPort(String)} with non-existent ports + * and how the serial transport classes handle it. + * + *

The behavior of {@code getCommPort} differs by platform: + * + *

+ * + *

In both cases the result is the same: {@code getCommPort} throws for non-existent ports. The + * transport classes defer this call to connect/bind time so the exception can be caught and wrapped + * as a {@link ModbusConnectException}. + */ +class SerialPortGetCommPortBehaviorTest { + + private static final String BOGUS_UNIX_PORT = "/dev/this_port_does_not_exist_xyz"; + private static final String BOGUS_WINDOWS_PORT = "COM999"; + + @Nested + @EnabledOnOs({OS.LINUX, OS.MAC}) + class GetCommPortOnLinuxMac { + + @Test + void getCommPortThrowsForNonExistentPortFile() { + // On Linux/Mac, getCommPort checks File.exists() for the port descriptor. + // A path that doesn't exist on the filesystem causes an immediate exception. + assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_UNIX_PORT)); + } + } + + @Nested + @EnabledOnOs(OS.WINDOWS) + class GetCommPortOnWindows { + + @Test + void getCommPortThrowsForInvalidComPort() { + // On Windows, getCommPort rewrites the descriptor to \\.\COMx format + // without checking file existence. The native retrievePortDetails() call + // throws for COM ports that don't exist. + assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_WINDOWS_PORT)); + } + } + + @Nested + class ClientTransport { + + @Test + void constructorDoesNotCallGetCommPort() { + // Construction must always succeed regardless of port name. + // getCommPort is deferred to getSerialPort()/connect(). + assertDoesNotThrow( + () -> SerialPortClientTransport.create(cfg -> cfg.setSerialPort(BOGUS_UNIX_PORT))); + } + + @Test + void getSerialPortWrapsExceptionAsModbusException() { + String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; + SerialPortClientTransport transport = + SerialPortClientTransport.create(cfg -> cfg.setSerialPort(port)); + + ModbusException ex = assertThrows(ModbusException.class, transport::getSerialPort); + assertTrue(ex.getMessage().contains(port)); + } + + @Test + void connectFailsWithModbusConnectException() { + String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; + SerialPortClientTransport transport = + SerialPortClientTransport.create(cfg -> cfg.setSerialPort(port)); + + CompletableFuture future = transport.connect(); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertInstanceOf(ModbusConnectException.class, ex.getCause()); + } + } + + @Nested + class ServerTransport { + + @Test + void constructorDoesNotCallGetCommPort() { + assertDoesNotThrow( + () -> SerialPortServerTransport.create(cfg -> cfg.setSerialPort(BOGUS_UNIX_PORT))); + } + + @Test + void getSerialPortWrapsExceptionAsModbusException() { + String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; + SerialPortServerTransport transport = + SerialPortServerTransport.create(cfg -> cfg.setSerialPort(port)); + + ModbusException ex = assertThrows(ModbusException.class, transport::getSerialPort); + assertTrue(ex.getMessage().contains(port)); + } + + @Test + void bindFailsWithModbusConnectException() { + String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; + SerialPortServerTransport transport = + SerialPortServerTransport.create(cfg -> cfg.setSerialPort(port)); + + CompletableFuture future = transport.bind().toCompletableFuture(); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertInstanceOf(ModbusConnectException.class, ex.getCause()); + } + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } +} From 8a345ff40d4e07fab284966b125247344e195920 Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 12:22:04 -0700 Subject: [PATCH 3/8] Build and test on multiple OS --- .github/workflows/maven.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index dc197ee..6719fe5 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -6,7 +6,10 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 From be8f6305d51d3b42ed66385c2df26737ff862fdd Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 12:28:09 -0700 Subject: [PATCH 4/8] Add OS-specific tests for cross-platform port descriptor behavior Add tests asserting that Windows-style descriptors (COM999) also fail on Linux/Mac, and that Unix-style descriptors (/dev/...) succeed at getCommPort on Windows but fail at openPort. Fix test expectations based on CI results: Windows getCommPort does not throw for bogus COM ports, deferring failure to openPort. --- .../SerialPortGetCommPortBehaviorTest.java | 93 +++++++++++++++---- 1 file changed, 75 insertions(+), 18 deletions(-) diff --git a/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java b/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java index 202d5ec..1935cc2 100644 --- a/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java +++ b/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java @@ -1,7 +1,9 @@ package com.digitalpetri.modbus.serial; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -27,14 +29,16 @@ *

  • Linux/macOS: validates that the port descriptor exists on the filesystem (checking * the path as-is, then with a {@code /dev/} prefix). Throws {@code * SerialPortInvalidPortException} immediately if the file does not exist. - *
  • Windows: does not check file existence — rewrites the descriptor to {@code - * \\.\COMx} format and defers validation to the native {@code retrievePortDetails()} call, - * which throws {@code SerialPortInvalidPortException} for invalid COM ports. + *
  • Windows: does not validate port existence — rewrites the descriptor to + * {@code \\.\COMx} format and creates the {@link SerialPort} object successfully. The failure + * is deferred to {@code openPort()}, which returns {@code false}. * * - *

    In both cases the result is the same: {@code getCommPort} throws for non-existent ports. The - * transport classes defer this call to connect/bind time so the exception can be caught and wrapped - * as a {@link ModbusConnectException}. + *

    This behavioral difference is why the transport classes defer the {@code getCommPort} call to + * connect/bind time rather than calling it in the constructor. On Linux/macOS the deferred call + * allows the exception to be caught and wrapped as a {@link ModbusConnectException}. On Windows the + * constructor would have succeeded either way, but the deferred approach keeps the behavior + * consistent across platforms. */ class SerialPortGetCommPortBehaviorTest { @@ -51,6 +55,13 @@ void getCommPortThrowsForNonExistentPortFile() { // A path that doesn't exist on the filesystem causes an immediate exception. assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_UNIX_PORT)); } + + @Test + void getCommPortThrowsForWindowsStyleDescriptor() { + // A Windows-style descriptor like "COM999" also fails on Linux/Mac because + // it doesn't exist on the filesystem (not under /dev/ either). + assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_WINDOWS_PORT)); + } } @Nested @@ -58,11 +69,32 @@ void getCommPortThrowsForNonExistentPortFile() { class GetCommPortOnWindows { @Test - void getCommPortThrowsForInvalidComPort() { + void getCommPortDoesNotThrowForNonExistentComPort() { // On Windows, getCommPort rewrites the descriptor to \\.\COMx format - // without checking file existence. The native retrievePortDetails() call - // throws for COM ports that don't exist. - assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_WINDOWS_PORT)); + // and does not validate that the port exists. The SerialPort object is + // created successfully; failure is deferred to openPort(). + SerialPort sp = assertDoesNotThrow(() -> SerialPort.getCommPort(BOGUS_WINDOWS_PORT)); + assertNotNull(sp); + } + + @Test + void getCommPortWithNonExistentComPortFailsToOpen() { + SerialPort sp = SerialPort.getCommPort(BOGUS_WINDOWS_PORT); + assertFalse(sp.openPort(), "opening a non-existent COM port should fail"); + } + + @Test + void getCommPortDoesNotThrowForUnixStyleDescriptor() { + // On Windows, getCommPort rewrites any descriptor to \\.\ format + // without filesystem validation, so even a Unix-style path succeeds. + SerialPort sp = assertDoesNotThrow(() -> SerialPort.getCommPort(BOGUS_UNIX_PORT)); + assertNotNull(sp); + } + + @Test + void getCommPortWithUnixStyleDescriptorFailsToOpen() { + SerialPort sp = SerialPort.getCommPort(BOGUS_UNIX_PORT); + assertFalse(sp.openPort(), "opening a Unix-style port on Windows should fail"); } } @@ -78,17 +110,33 @@ void constructorDoesNotCallGetCommPort() { } @Test - void getSerialPortWrapsExceptionAsModbusException() { - String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; + @EnabledOnOs({OS.LINUX, OS.MAC}) + void getSerialPortThrowsOnLinuxMac() { SerialPortClientTransport transport = - SerialPortClientTransport.create(cfg -> cfg.setSerialPort(port)); + SerialPortClientTransport.create(cfg -> cfg.setSerialPort(BOGUS_UNIX_PORT)); + // On Linux/Mac, getCommPort throws for non-existent ports, + // which getSerialPort wraps as ModbusException. ModbusException ex = assertThrows(ModbusException.class, transport::getSerialPort); - assertTrue(ex.getMessage().contains(port)); + assertTrue(ex.getMessage().contains(BOGUS_UNIX_PORT)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void getSerialPortSucceedsOnWindows() throws ModbusException { + SerialPortClientTransport transport = + SerialPortClientTransport.create(cfg -> cfg.setSerialPort(BOGUS_WINDOWS_PORT)); + + // On Windows, getCommPort succeeds for non-existent COM ports, + // so getSerialPort also succeeds. Failure is deferred to connect/openPort. + assertDoesNotThrow(transport::getSerialPort); } @Test void connectFailsWithModbusConnectException() { + // On all platforms, connect with a bogus port results in ModbusConnectException. + // On Linux/Mac: getSerialPort() throws, caught and wrapped. + // On Windows: getSerialPort() succeeds, but openPort() returns false. String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; SerialPortClientTransport transport = SerialPortClientTransport.create(cfg -> cfg.setSerialPort(port)); @@ -109,13 +157,22 @@ void constructorDoesNotCallGetCommPort() { } @Test - void getSerialPortWrapsExceptionAsModbusException() { - String port = isWindows() ? BOGUS_WINDOWS_PORT : BOGUS_UNIX_PORT; + @EnabledOnOs({OS.LINUX, OS.MAC}) + void getSerialPortThrowsOnLinuxMac() { SerialPortServerTransport transport = - SerialPortServerTransport.create(cfg -> cfg.setSerialPort(port)); + SerialPortServerTransport.create(cfg -> cfg.setSerialPort(BOGUS_UNIX_PORT)); ModbusException ex = assertThrows(ModbusException.class, transport::getSerialPort); - assertTrue(ex.getMessage().contains(port)); + assertTrue(ex.getMessage().contains(BOGUS_UNIX_PORT)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void getSerialPortSucceedsOnWindows() { + SerialPortServerTransport transport = + SerialPortServerTransport.create(cfg -> cfg.setSerialPort(BOGUS_WINDOWS_PORT)); + + assertDoesNotThrow(transport::getSerialPort); } @Test From 0b57bae3b590e3775e27096cd7b0a619897a815f Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 13:35:04 -0700 Subject: [PATCH 5/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../modbus/serial/client/SerialPortClientTransport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java index 2e75f7c..50a1959 100644 --- a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java +++ b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java @@ -79,7 +79,7 @@ public synchronized CompletableFuture connect() { sp = getSerialPort(); } catch (ModbusException e) { return CompletableFuture.failedFuture( - new ModbusConnectException(e.getMessage(), e.getCause())); + new ModbusConnectException(e.getMessage(), e)); } if (sp.isOpen()) { From 0fc296b457f6641073368d770834fc101838c6bb Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 13:35:13 -0700 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../modbus/serial/client/SerialPortClientTransport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java index 50a1959..451de3d 100644 --- a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java +++ b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/client/SerialPortClientTransport.java @@ -141,7 +141,7 @@ public boolean isConnected() { @Override public CompletionStage send(ModbusRtuFrame frame) { SerialPort sp = this.serialPort; - if (sp == null) { + if (sp == null || !sp.isOpen()) { return CompletableFuture.failedFuture(new ModbusException("not connected")); } From 9b1f952cb2cc02c8624895e60a56d9d429e315ed Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 13:35:59 -0700 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../modbus/serial/SerialPortGetCommPortBehaviorTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java b/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java index 1935cc2..972b108 100644 --- a/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java +++ b/modbus-serial/src/test/java/com/digitalpetri/modbus/serial/SerialPortGetCommPortBehaviorTest.java @@ -12,6 +12,7 @@ import com.digitalpetri.modbus.serial.client.SerialPortClientTransport; import com.digitalpetri.modbus.serial.server.SerialPortServerTransport; import com.fazecast.jSerialComm.SerialPort; +import com.fazecast.jSerialComm.SerialPortInvalidPortException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.junit.jupiter.api.Nested; @@ -53,14 +54,14 @@ class GetCommPortOnLinuxMac { void getCommPortThrowsForNonExistentPortFile() { // On Linux/Mac, getCommPort checks File.exists() for the port descriptor. // A path that doesn't exist on the filesystem causes an immediate exception. - assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_UNIX_PORT)); + assertThrows(SerialPortInvalidPortException.class, () -> SerialPort.getCommPort(BOGUS_UNIX_PORT)); } @Test void getCommPortThrowsForWindowsStyleDescriptor() { // A Windows-style descriptor like "COM999" also fails on Linux/Mac because // it doesn't exist on the filesystem (not under /dev/ either). - assertThrows(RuntimeException.class, () -> SerialPort.getCommPort(BOGUS_WINDOWS_PORT)); + assertThrows(SerialPortInvalidPortException.class, () -> SerialPort.getCommPort(BOGUS_WINDOWS_PORT)); } } From 893f6b9fd4e9dd3a39c274123622d8a1979446fc Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sat, 14 Mar 2026 13:37:34 -0700 Subject: [PATCH 8/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../modbus/serial/server/SerialPortServerTransport.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java index a356c6f..eb5a36c 100644 --- a/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java +++ b/modbus-serial/src/main/java/com/digitalpetri/modbus/serial/server/SerialPortServerTransport.java @@ -86,8 +86,7 @@ public CompletionStage bind() { try { sp = getSerialPort(); } catch (ModbusException e) { - return CompletableFuture.failedFuture( - new ModbusConnectException(e.getMessage(), e.getCause())); + return CompletableFuture.failedFuture(new ModbusConnectException(e)); } if (sp.isOpen()) {