This is an automated email from the ASF dual-hosted git repository.
sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
The following commit(s) were added to refs/heads/develop by this push:
new 420e7e6a39 fix(plc4j/modbus): validate response function codes and
make request-timeout cover queueing
420e7e6a39 is described below
commit 420e7e6a39eada49b96825ab243686fa6ba8ec43
Author: Sebastian Rühl <[email protected]>
AuthorDate: Tue Jul 7 13:51:54 2026 +0200
fix(plc4j/modbus): validate response function codes and make
request-timeout cover queueing
Serial connections (RTU/ASCII) correlated responses by unit id only, so a
late response from a timed-out request could complete the next request on
the same unit. Responses are now also validated against the pending
request's function code (exception frames answer any request; the generated
error PDU carries no fc). Mismatches are logged and discarded while the
pending request stays armed. Same-fc late responses remain
indistinguishable — inherent to RTU, documented.
The request timeout is now armed at submission instead of dispatch, making
it a total wall-clock budget; requests that expire while queued fail fast
with a TimeoutException and never reach the wire (small dispatch margin,
documented in the config description).
---
RELEASE_NOTES | 4 +
.../java/modbus/ascii/ModbusAsciiConnection.java | 125 ++++++++++++++++-----
.../ascii/config/ModbusAsciiConfiguration.java | 2 +-
.../plc4x/java/modbus/rtu/ModbusRtuConnection.java | 125 ++++++++++++++++-----
.../modbus/rtu/config/ModbusRtuConfiguration.java | 2 +-
.../modbus/ascii/ModbusAsciiConnectionTest.java | 31 +++--
.../rtu/ModbusRtuConnectionRequestChainTest.java | 74 +++++++++++-
.../java/modbus/rtu/ModbusRtuConnectionTest.java | 31 +++--
.../transport/serial/SerialTransportInstance.java | 3 +-
.../transport/serial/SharedModeInstanceTest.java | 29 +++++
10 files changed, 344 insertions(+), 82 deletions(-)
diff --git a/RELEASE_NOTES b/RELEASE_NOTES
index a2424b1b12..5cf58c4c37 100644
--- a/RELEASE_NOTES
+++ b/RELEASE_NOTES
@@ -38,6 +38,10 @@ New Features
unknown Modbus exception codes map to REMOTE_ERROR instead of failing;
shared serial ports dispatch each connection's callbacks on their own
thread, so one blocked callback no longer stalls the whole port.
+ Responses are additionally validated against the pending request's
+ function code (late responses from timed-out requests are discarded
+ instead of completing the wrong caller), and the request timeout now
+ covers the full time from submission, including queueing.
Incompatible changes
--------------------
diff --git
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnection.java
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnection.java
index cc4d702310..c97e2f641a 100644
---
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnection.java
+++
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnection.java
@@ -64,7 +64,7 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
private static final Logger LOGGER =
LoggerFactory.getLogger(ModbusAsciiConnection.class);
private ModbusAsciiMessageCodec messageCodec;
- private final Map<Short, CompletableFuture<ModbusAsciiADU>>
pendingRequests = new ConcurrentHashMap<>();
+ private final Map<Short, PendingRequest> pendingRequests = new
ConcurrentHashMap<>();
// Serializes request/response transactions. Modbus over serial is a
// single-outstanding-transaction protocol; without this, concurrent
@@ -107,8 +107,8 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
if (messageCodec != null) {
messageCodec.close();
}
- pendingRequests.values().forEach(future ->
- future.completeExceptionally(new PlcRuntimeException("Connection
closed")));
+ pendingRequests.values().forEach(pending ->
+ pending.future.completeExceptionally(new
PlcRuntimeException("Connection closed")));
pendingRequests.clear();
super.close();
LOGGER.info("Modbus ASCII connection closed");
@@ -126,7 +126,7 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
int pendingCount = pendingRequests.size();
if (pendingCount > 0) {
LOGGER.warn("Failing {} pending requests due to transport
disconnect", pendingCount);
- pendingRequests.values().forEach(future ->
future.completeExceptionally(exception));
+ pendingRequests.values().forEach(pending ->
pending.future.completeExceptionally(exception));
pendingRequests.clear();
}
}
@@ -147,9 +147,31 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
}
/**
- * Correlation is by unit id only; requests are serialized per connection
- * via {@link #sendRequest}, and per shared port each connection must own
- * distinct unit ids.
+ * A dispatched request awaiting its response: the future plus the
+ * request's function code for response validation.
+ */
+ private static final class PendingRequest {
+ final byte functionFlag;
+ final CompletableFuture<ModbusAsciiADU> future;
+ PendingRequest(byte functionFlag, CompletableFuture<ModbusAsciiADU>
future) {
+ this.functionFlag = functionFlag;
+ this.future = future;
+ }
+ }
+
+ /**
+ * Correlates a received frame to the pending request for its unit id.
+ * Validation: an exception frame (errorFlag) answers any pending
+ * request; a normal frame must carry the SAME function code as the
+ * pending request — mismatches (e.g. a late response arriving after
+ * its request timed out and a different operation took its place) are
+ * discarded without touching the pending entry.
+ * <p>
+ * Residual limitation: a late response with the SAME function code as
+ * the new request is physically indistinguishable — Modbus RTU/ASCII
+ * carry no transaction ids. Requests are serialized per connection and
+ * shared ports require distinct unit ids per connection, which bounds
+ * the exposure to same-fc retry patterns.
*/
private void handleIncomingMessage(ModbusAsciiADU modbusMessage) {
short address = modbusMessage.getAddress();
@@ -157,17 +179,61 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
auditLog.write(AuditLogEventType.INCOMING_MESSAGE,
"Received Modbus ASCII response, address=" + address);
}
- CompletableFuture<ModbusAsciiADU> future =
pendingRequests.remove(address);
- if (future != null) {
- future.complete(modbusMessage);
- } else {
+ PendingRequest pending = pendingRequests.get(address);
+ if (pending == null) {
LOGGER.warn("Received response for unknown address: {}", address);
+ return;
}
+ boolean isException = modbusMessage.getPdu().getErrorFlag();
+ if (!isException && modbusMessage.getPdu().getFunctionFlag() !=
pending.functionFlag) {
+ LOGGER.warn("Discarding response for address {} with function code
0x{} while waiting for 0x{} (late response from a timed-out request?)",
+ address,
+ Integer.toHexString(modbusMessage.getPdu().getFunctionFlag() &
0xFF),
+ Integer.toHexString(pending.functionFlag & 0xFF));
+ return;
+ }
+ pendingRequests.remove(address, pending);
+ pending.future.complete(modbusMessage);
}
// Package-private for tests.
CompletableFuture<ModbusAsciiADU> sendRequest(ModbusAsciiADU request,
short address) {
CompletableFuture<ModbusAsciiADU> responseFuture = new
CompletableFuture<>();
+
+ // Total budget from submission: queueing + dispatch + response.
+ long timeoutMs = getConfiguration().getRequestTimeout();
+ // Explicit deadline, checked again in dispatchRequest(): the JVM-wide
+ // CompletableFuture delay scheduler is a single thread and only gets
+ // back around to an already-overdue timeout AFTER it finishes
+ // processing the previous request's own completion (cleanup +
+ // chain-continuation + submitting the next dispatch) — that hand-off
+ // to the async pool routinely wins the race against the scheduler
+ // catching up, so responseFuture.isDone() alone can still be false
+ // for a request whose enqueue-time budget has, in wall-clock terms,
+ // already run out.
+ long nowNanos = System.nanoTime();
+ long budgetNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+ // A request with only a sliver of its budget left is not worth
+ // dispatching either — it has no realistic chance of a round trip
+ // completing before its own timeout fires anyway. Backing the
+ // dispatch deadline off by this margin also absorbs scheduling
+ // jitter between a predecessor's timeout firing (on the JVM-wide
+ // single-threaded delay scheduler, see above) and this request's
+ // own deadline — queued only a hair later, on the same clock —
+ // which would otherwise let a request that is, for all practical
+ // purposes, dead slip through a raw "has my deadline passed" check.
+ long minViableRemainingNanos =
Math.min(TimeUnit.MILLISECONDS.toNanos(50), budgetNanos / 4);
+ long dispatchDeadlineNanos = nowNanos + budgetNanos -
minViableRemainingNanos;
+ responseFuture.orTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+ .whenComplete((result, error) -> {
+ if (error instanceof TimeoutException) {
+ // Two-arg remove semantics preserved through the holder:
+ // only clear the entry if it is still OUR request.
+ pendingRequests.computeIfPresent(address,
+ (key, pending) -> pending.future == responseFuture ?
null : pending);
+ }
+ });
+
CompletableFuture<?> previous;
synchronized (requestChainLock) {
previous = requestTail;
@@ -177,18 +243,30 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
}
if (previous.isDone()) {
// Hot path: nothing queued — dispatch on the caller thread as
before.
- dispatchRequest(request, address, responseFuture);
+ dispatchRequest(request, address, responseFuture,
dispatchDeadlineNanos);
} else {
// Queued: hop to the async pool so long bursts release the
// completing thread's stack (a synchronous chain nests one
// frame per queued request and can silently overflow).
- previous.whenCompleteAsync((ignored, ignoredError) ->
dispatchRequest(request, address, responseFuture));
+ previous.whenCompleteAsync((ignored, ignoredError) ->
dispatchRequest(request, address, responseFuture, dispatchDeadlineNanos));
}
return responseFuture;
}
- private void dispatchRequest(ModbusAsciiADU request, short address,
CompletableFuture<ModbusAsciiADU> responseFuture) {
- pendingRequests.put(address, responseFuture);
+ private void dispatchRequest(ModbusAsciiADU request, short address,
CompletableFuture<ModbusAsciiADU> responseFuture, long dispatchDeadlineNanos) {
+ if (responseFuture.isDone()) {
+ // Already completed (timeout or failure) while queued.
+ return;
+ }
+ if (System.nanoTime() >= dispatchDeadlineNanos) {
+ // Fail fast: the remaining budget is below the dispatch margin,
+ // so the request is semantically dead — don't make the caller
+ // wait for the orTimeout backstop.
+ responseFuture.completeExceptionally(new TimeoutException(
+ "Request timed out while queued (remaining budget below
dispatch margin)"));
+ return;
+ }
+ pendingRequests.put(address, new
PendingRequest(request.getPdu().getFunctionFlag(), responseFuture));
try {
if (auditLog.isEnabled()) {
auditLog.write(AuditLogEventType.OUTGOING_MESSAGE,
@@ -196,29 +274,16 @@ public class ModbusAsciiConnection extends
ConnectionBase<ModbusAsciiConfigurati
}
messageCodec.send(request);
} catch (MessageCodecException e) {
- pendingRequests.remove(address);
+ pendingRequests.computeIfPresent(address, (key, pending) ->
pending.future == responseFuture ? null : pending);
responseFuture.completeExceptionally(new
PlcRuntimeException("Failed to send request", e));
- return;
} catch (RuntimeException e) {
// The responseFuture MUST complete no matter what: the request
// chain's tail hangs off it, so an unchecked throw here (e.g.
// from a custom AuditLog) would otherwise silently wedge every
// subsequent request on this connection.
- pendingRequests.remove(address, responseFuture);
+ pendingRequests.computeIfPresent(address, (key, pending) ->
pending.future == responseFuture ? null : pending);
responseFuture.completeExceptionally(e);
- return;
}
-
- long timeoutMs = getConfiguration().getRequestTimeout();
- responseFuture.orTimeout(timeoutMs, TimeUnit.MILLISECONDS)
- .whenComplete((result, error) -> {
- if (error instanceof TimeoutException) {
- // Two-arg remove: only clears our own entry, immune to
- // dependent-ordering (a late cleanup can never remove a
- // successor request's entry).
- pendingRequests.remove(address, responseFuture);
- }
- });
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
diff --git
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java
index 9bba5b0aa0..4cc21b729c 100644
---
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java
+++
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java
@@ -30,7 +30,7 @@ public class ModbusAsciiConfiguration implements
Configuration {
@ConfigurationParameter("request-timeout")
@IntDefaultValue(5_000)
- @Description("Default timeout for all types of requests.")
+ @Description("Default timeout for all types of requests. The timeout
covers the full time from submission including queueing; queued requests whose
remaining budget falls below a small dispatch margin (at most a quarter of the
timeout, capped at 50 ms) fail fast instead of being sent.")
private int requestTimeout;
@ConfigurationParameter("default-unit-identifier")
diff --git
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnection.java
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnection.java
index f3c7165fa5..4a9e7b789a 100644
---
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnection.java
+++
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnection.java
@@ -64,7 +64,7 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
private static final Logger LOGGER =
LoggerFactory.getLogger(ModbusRtuConnection.class);
private ModbusRtuMessageCodec messageCodec;
- private final Map<Short, CompletableFuture<ModbusRtuADU>> pendingRequests
= new ConcurrentHashMap<>();
+ private final Map<Short, PendingRequest> pendingRequests = new
ConcurrentHashMap<>();
// Serializes request/response transactions. Modbus over serial is a
// single-outstanding-transaction protocol; without this, concurrent
@@ -107,8 +107,8 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
if (messageCodec != null) {
messageCodec.close();
}
- pendingRequests.values().forEach(future ->
- future.completeExceptionally(new PlcRuntimeException("Connection
closed")));
+ pendingRequests.values().forEach(pending ->
+ pending.future.completeExceptionally(new
PlcRuntimeException("Connection closed")));
pendingRequests.clear();
super.close();
LOGGER.info("Modbus RTU connection closed");
@@ -126,7 +126,7 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
int pendingCount = pendingRequests.size();
if (pendingCount > 0) {
LOGGER.warn("Failing {} pending requests due to transport
disconnect", pendingCount);
- pendingRequests.values().forEach(future ->
future.completeExceptionally(exception));
+ pendingRequests.values().forEach(pending ->
pending.future.completeExceptionally(exception));
pendingRequests.clear();
}
}
@@ -147,9 +147,31 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
}
/**
- * Correlation is by unit id only; requests are serialized per connection
- * via {@link #sendRequest}, and per shared port each connection must own
- * distinct unit ids.
+ * A dispatched request awaiting its response: the future plus the
+ * request's function code for response validation.
+ */
+ private static final class PendingRequest {
+ final byte functionFlag;
+ final CompletableFuture<ModbusRtuADU> future;
+ PendingRequest(byte functionFlag, CompletableFuture<ModbusRtuADU>
future) {
+ this.functionFlag = functionFlag;
+ this.future = future;
+ }
+ }
+
+ /**
+ * Correlates a received frame to the pending request for its unit id.
+ * Validation: an exception frame (errorFlag) answers any pending
+ * request; a normal frame must carry the SAME function code as the
+ * pending request — mismatches (e.g. a late response arriving after
+ * its request timed out and a different operation took its place) are
+ * discarded without touching the pending entry.
+ * <p>
+ * Residual limitation: a late response with the SAME function code as
+ * the new request is physically indistinguishable — Modbus RTU/ASCII
+ * carry no transaction ids. Requests are serialized per connection and
+ * shared ports require distinct unit ids per connection, which bounds
+ * the exposure to same-fc retry patterns.
*/
private void handleIncomingMessage(ModbusRtuADU modbusMessage) {
short address = modbusMessage.getAddress();
@@ -157,17 +179,61 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
auditLog.write(AuditLogEventType.INCOMING_MESSAGE,
"Received Modbus RTU response, address=" + address);
}
- CompletableFuture<ModbusRtuADU> future =
pendingRequests.remove(address);
- if (future != null) {
- future.complete(modbusMessage);
- } else {
+ PendingRequest pending = pendingRequests.get(address);
+ if (pending == null) {
LOGGER.warn("Received response for unknown address: {}", address);
+ return;
}
+ boolean isException = modbusMessage.getPdu().getErrorFlag();
+ if (!isException && modbusMessage.getPdu().getFunctionFlag() !=
pending.functionFlag) {
+ LOGGER.warn("Discarding response for address {} with function code
0x{} while waiting for 0x{} (late response from a timed-out request?)",
+ address,
+ Integer.toHexString(modbusMessage.getPdu().getFunctionFlag() &
0xFF),
+ Integer.toHexString(pending.functionFlag & 0xFF));
+ return;
+ }
+ pendingRequests.remove(address, pending);
+ pending.future.complete(modbusMessage);
}
// Package-private for tests.
CompletableFuture<ModbusRtuADU> sendRequest(ModbusRtuADU request, short
address) {
CompletableFuture<ModbusRtuADU> responseFuture = new
CompletableFuture<>();
+
+ // Total budget from submission: queueing + dispatch + response.
+ long timeoutMs = getConfiguration().getRequestTimeout();
+ // Explicit deadline, checked again in dispatchRequest(): the JVM-wide
+ // CompletableFuture delay scheduler is a single thread and only gets
+ // back around to an already-overdue timeout AFTER it finishes
+ // processing the previous request's own completion (cleanup +
+ // chain-continuation + submitting the next dispatch) — that hand-off
+ // to the async pool routinely wins the race against the scheduler
+ // catching up, so responseFuture.isDone() alone can still be false
+ // for a request whose enqueue-time budget has, in wall-clock terms,
+ // already run out.
+ long nowNanos = System.nanoTime();
+ long budgetNanos = TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+ // A request with only a sliver of its budget left is not worth
+ // dispatching either — it has no realistic chance of a round trip
+ // completing before its own timeout fires anyway. Backing the
+ // dispatch deadline off by this margin also absorbs scheduling
+ // jitter between a predecessor's timeout firing (on the JVM-wide
+ // single-threaded delay scheduler, see above) and this request's
+ // own deadline — queued only a hair later, on the same clock —
+ // which would otherwise let a request that is, for all practical
+ // purposes, dead slip through a raw "has my deadline passed" check.
+ long minViableRemainingNanos =
Math.min(TimeUnit.MILLISECONDS.toNanos(50), budgetNanos / 4);
+ long dispatchDeadlineNanos = nowNanos + budgetNanos -
minViableRemainingNanos;
+ responseFuture.orTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+ .whenComplete((result, error) -> {
+ if (error instanceof TimeoutException) {
+ // Two-arg remove semantics preserved through the holder:
+ // only clear the entry if it is still OUR request.
+ pendingRequests.computeIfPresent(address,
+ (key, pending) -> pending.future == responseFuture ?
null : pending);
+ }
+ });
+
CompletableFuture<?> previous;
synchronized (requestChainLock) {
previous = requestTail;
@@ -177,18 +243,30 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
}
if (previous.isDone()) {
// Hot path: nothing queued — dispatch on the caller thread as
before.
- dispatchRequest(request, address, responseFuture);
+ dispatchRequest(request, address, responseFuture,
dispatchDeadlineNanos);
} else {
// Queued: hop to the async pool so long bursts release the
// completing thread's stack (a synchronous chain nests one
// frame per queued request and can silently overflow).
- previous.whenCompleteAsync((ignored, ignoredError) ->
dispatchRequest(request, address, responseFuture));
+ previous.whenCompleteAsync((ignored, ignoredError) ->
dispatchRequest(request, address, responseFuture, dispatchDeadlineNanos));
}
return responseFuture;
}
- private void dispatchRequest(ModbusRtuADU request, short address,
CompletableFuture<ModbusRtuADU> responseFuture) {
- pendingRequests.put(address, responseFuture);
+ private void dispatchRequest(ModbusRtuADU request, short address,
CompletableFuture<ModbusRtuADU> responseFuture, long dispatchDeadlineNanos) {
+ if (responseFuture.isDone()) {
+ // Already completed (timeout or failure) while queued.
+ return;
+ }
+ if (System.nanoTime() >= dispatchDeadlineNanos) {
+ // Fail fast: the remaining budget is below the dispatch margin,
+ // so the request is semantically dead — don't make the caller
+ // wait for the orTimeout backstop.
+ responseFuture.completeExceptionally(new TimeoutException(
+ "Request timed out while queued (remaining budget below
dispatch margin)"));
+ return;
+ }
+ pendingRequests.put(address, new
PendingRequest(request.getPdu().getFunctionFlag(), responseFuture));
try {
if (auditLog.isEnabled()) {
auditLog.write(AuditLogEventType.OUTGOING_MESSAGE,
@@ -196,29 +274,16 @@ public class ModbusRtuConnection extends
ConnectionBase<ModbusRtuConfiguration>
}
messageCodec.send(request);
} catch (MessageCodecException e) {
- pendingRequests.remove(address);
+ pendingRequests.computeIfPresent(address, (key, pending) ->
pending.future == responseFuture ? null : pending);
responseFuture.completeExceptionally(new
PlcRuntimeException("Failed to send request", e));
- return;
} catch (RuntimeException e) {
// The responseFuture MUST complete no matter what: the request
// chain's tail hangs off it, so an unchecked throw here (e.g.
// from a custom AuditLog) would otherwise silently wedge every
// subsequent request on this connection.
- pendingRequests.remove(address, responseFuture);
+ pendingRequests.computeIfPresent(address, (key, pending) ->
pending.future == responseFuture ? null : pending);
responseFuture.completeExceptionally(e);
- return;
}
-
- long timeoutMs = getConfiguration().getRequestTimeout();
- responseFuture.orTimeout(timeoutMs, TimeUnit.MILLISECONDS)
- .whenComplete((result, error) -> {
- if (error instanceof TimeoutException) {
- // Two-arg remove: only clears our own entry, immune to
- // dependent-ordering (a late cleanup can never remove a
- // successor request's entry).
- pendingRequests.remove(address, responseFuture);
- }
- });
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
diff --git
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java
index 1e6eca0eae..b5165dbcab 100644
---
a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java
+++
b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java
@@ -30,7 +30,7 @@ public class ModbusRtuConfiguration implements Configuration {
@ConfigurationParameter("request-timeout")
@IntDefaultValue(5_000)
- @Description("Default timeout for all types of requests.")
+ @Description("Default timeout for all types of requests. The timeout
covers the full time from submission including queueing; queued requests whose
remaining budget falls below a small dispatch margin (at most a quarter of the
timeout, capped at 50 ms) fail fast instead of being sent.")
private int requestTimeout;
@ConfigurationParameter("default-unit-identifier")
diff --git
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnectionTest.java
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnectionTest.java
index 1ff97851e8..7dcccde7a4 100644
---
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnectionTest.java
+++
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ascii/ModbusAsciiConnectionTest.java
@@ -34,6 +34,7 @@ import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
@@ -199,6 +200,21 @@ class ModbusAsciiConnectionTest {
assertEquals(ModbusByteOrder.LITTLE_ENDIAN, method.invoke(connection,
tag));
}
+ /**
+ * pendingRequests holds {@code PendingRequest} (function-code-tagged)
+ * holders rather than raw futures; this reflects the holder in the
+ * same way production code does (byte functionFlag, CompletableFuture
future).
+ */
+ private static Object newPendingRequest(byte functionFlag,
CompletableFuture<ModbusAsciiADU> future) throws Exception {
+ Class<?> pendingRequestClass =
Arrays.stream(ModbusAsciiConnection.class.getDeclaredClasses())
+ .filter(c -> c.getSimpleName().equals("PendingRequest"))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("PendingRequest
nested class not found"));
+ Constructor<?> constructor =
pendingRequestClass.getDeclaredConstructor(byte.class, CompletableFuture.class);
+ constructor.setAccessible(true);
+ return constructor.newInstance(functionFlag, future);
+ }
+
@Test
void testHandleIncomingMessage() throws Exception {
Method method =
ModbusAsciiConnection.class.getDeclaredMethod("handleIncomingMessage",
ModbusAsciiADU.class);
@@ -207,11 +223,10 @@ class ModbusAsciiConnectionTest {
Field pendingField =
ModbusAsciiConnection.class.getDeclaredField("pendingRequests");
pendingField.setAccessible(true);
@SuppressWarnings("unchecked")
- Map<Short, CompletableFuture<ModbusAsciiADU>> pendingRequests =
- (Map<Short, CompletableFuture<ModbusAsciiADU>>)
pendingField.get(connection);
+ Map<Short, Object> pendingRequests = (Map<Short, Object>)
pendingField.get(connection);
CompletableFuture<ModbusAsciiADU> future = new CompletableFuture<>();
- pendingRequests.put((short) 1, future);
+ pendingRequests.put((short) 1, newPendingRequest((byte) 0x03, future));
ModbusAsciiADU responseAdu = new ModbusAsciiADU((short) 1,
new ModbusPDUReadHoldingRegistersResponse(new byte[]{0x00, 0x2A}));
@@ -235,11 +250,10 @@ class ModbusAsciiConnectionTest {
Field pendingField =
ModbusAsciiConnection.class.getDeclaredField("pendingRequests");
pendingField.setAccessible(true);
@SuppressWarnings("unchecked")
- Map<Short, CompletableFuture<ModbusAsciiADU>> pendingRequests =
- (Map<Short, CompletableFuture<ModbusAsciiADU>>)
pendingField.get(connection);
+ Map<Short, Object> pendingRequests = (Map<Short, Object>)
pendingField.get(connection);
CompletableFuture<ModbusAsciiADU> future = new CompletableFuture<>();
- pendingRequests.put((short) 1, future);
+ pendingRequests.put((short) 1, newPendingRequest((byte) 0x03, future));
connection.close();
@@ -252,11 +266,10 @@ class ModbusAsciiConnectionTest {
Field pendingField =
ModbusAsciiConnection.class.getDeclaredField("pendingRequests");
pendingField.setAccessible(true);
@SuppressWarnings("unchecked")
- Map<Short, CompletableFuture<ModbusAsciiADU>> pendingRequests =
- (Map<Short, CompletableFuture<ModbusAsciiADU>>)
pendingField.get(connection);
+ Map<Short, Object> pendingRequests = (Map<Short, Object>)
pendingField.get(connection);
CompletableFuture<ModbusAsciiADU> future = new CompletableFuture<>();
- pendingRequests.put((short) 1, future);
+ pendingRequests.put((short) 1, newPendingRequest((byte) 0x03, future));
Method method =
ModbusAsciiConnection.class.getDeclaredMethod("onTransportDisconnected",
Throwable.class);
method.setAccessible(true);
diff --git
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
index 394d646602..3a93031132 100644
---
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
+++
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionRequestChainTest.java
@@ -18,9 +18,12 @@
*/
package org.apache.plc4x.java.modbus.rtu;
+import org.apache.plc4x.java.modbus.readwrite.ModbusErrorCode;
import org.apache.plc4x.java.modbus.readwrite.ModbusPDU;
+import org.apache.plc4x.java.modbus.readwrite.ModbusPDUError;
import
org.apache.plc4x.java.modbus.readwrite.ModbusPDUReadHoldingRegistersRequest;
import
org.apache.plc4x.java.modbus.readwrite.ModbusPDUReadHoldingRegistersResponse;
+import
org.apache.plc4x.java.modbus.readwrite.ModbusPDUWriteSingleRegisterResponse;
import org.apache.plc4x.java.modbus.readwrite.ModbusRtuADU;
import org.apache.plc4x.java.modbus.rtu.config.ModbusRtuConfiguration;
import org.apache.plc4x.java.modbus.types.ModbusByteOrder;
@@ -44,9 +47,11 @@ import java.util.function.BooleanSupplier;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -147,13 +152,80 @@ class ModbusRtuConnectionRequestChainTest {
}
}
+ @Test
+ void mismatchedFunctionCodeIsDiscardedAndPendingSurvives() throws
Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ ModbusRtuConnection connection = newConnectedConnection(transport);
+
+ CompletableFuture<ModbusRtuADU> pendingRead = connection.sendRequest(
+ readHoldingRegistersRequestAdu(1, 0x0000, 1), (short) 1); // fc
0x03
+
+ // A write-echo frame (fc 0x06) for the same unit must NOT complete it.
+ transport.deliver(wireBytes(new ModbusRtuADU((short) 1,
+ new ModbusPDUWriteSingleRegisterResponse(0x0010, 0x1234))));
+ transport.runDataListener();
+ assertFalse(pendingRead.isDone(), "mismatched fc must be discarded,
pending must survive");
+
+ // The genuine 0x03 response still completes it.
+ transport.deliver(readResponseFrame(1, new byte[]{0x11, 0x22}));
+ transport.runDataListener();
+ assertArrayEquals(new byte[]{0x11, 0x22},
+ extractRegisterBytes(pendingRead.get(2, TimeUnit.SECONDS)));
+ }
+
+ @Test
+ void exceptionResponseCompletesAnyPendingRequest() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ ModbusRtuConnection connection = newConnectedConnection(transport);
+
+ CompletableFuture<ModbusRtuADU> pendingRead = connection.sendRequest(
+ readHoldingRegistersRequestAdu(1, 0x0000, 1), (short) 1);
+
+ transport.deliver(wireBytes(new ModbusRtuADU((short) 1,
+ new ModbusPDUError(ModbusErrorCode.ILLEGAL_DATA_ADDRESS))));
+ transport.runDataListener();
+
+ ModbusRtuADU response = pendingRead.get(2, TimeUnit.SECONDS);
+ assertTrue(response.getPdu().getErrorFlag(), "exception frames answer
any pending request");
+ }
+
+ @Test
+ void requestTimedOutWhileQueuedNeverReachesTheWire() throws Exception {
+ ScriptedAsyncTransport transport = new ScriptedAsyncTransport();
+ // Short request timeout so the queued request's total budget expires
+ // while the head transaction is still outstanding.
+ ModbusRtuConnection connection = newConnectedConnection(transport,
200);
+
+ // Head request dispatches and stays pending (never answered).
+ CompletableFuture<ModbusRtuADU> head = connection.sendRequest(
+ readHoldingRegistersRequestAdu(1, 0x0000, 1), (short) 1);
+ awaitTrue(() -> transport.writeCount() == 1, 2, TimeUnit.SECONDS);
+
+ // Second request queues behind it and must time out WHILE QUEUED.
+ CompletableFuture<ModbusRtuADU> queued = connection.sendRequest(
+ readHoldingRegistersRequestAdu(1, 0x0010, 1), (short) 1);
+ assertThrows(ExecutionException.class, () -> queued.get(2,
TimeUnit.SECONDS),
+ "queued request must time out from its enqueue-time clock");
+
+ // Head times out too (same 200ms clock); the chain then dispatches the
+ // dead queued request — which must be SKIPPED: no second wire write.
+ assertThrows(ExecutionException.class, () -> head.get(2,
TimeUnit.SECONDS));
+ Thread.sleep(200); // give the chain's async dispatch a beat
+ assertEquals(1, transport.writeCount(),
+ "a request that died in the queue must never reach the wire");
+ }
+
/**
* Builds a connection wired to the given fake transport, and drives it
* through the same construction/connect path as {@code
ModbusRtuConnectionTest}.
*/
private static ModbusRtuConnection
newConnectedConnection(ScriptedAsyncTransport transport) throws Exception {
+ return newConnectedConnection(transport, 5000);
+ }
+
+ private static ModbusRtuConnection
newConnectedConnection(ScriptedAsyncTransport transport, int requestTimeoutMs)
throws Exception {
ModbusRtuConfiguration config = new ModbusRtuConfiguration();
- config.setRequestTimeout(5000);
+ config.setRequestTimeout(requestTimeoutMs);
config.setDefaultUnitIdentifier(1);
config.setPingAddress("4x00001:BOOL");
config.setDefaultPayloadByteOrder(ModbusByteOrder.BIG_ENDIAN);
diff --git
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionTest.java
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionTest.java
index ebcc39f7b1..2035fd269a 100644
---
a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionTest.java
+++
b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/rtu/ModbusRtuConnectionTest.java
@@ -34,6 +34,7 @@ import org.apache.plc4x.java.utils.auditlog.api.AuditLog;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
@@ -211,6 +212,21 @@ class ModbusRtuConnectionTest {
assertEquals(ModbusByteOrder.LITTLE_ENDIAN, method.invoke(connection,
tag));
}
+ /**
+ * pendingRequests holds {@code PendingRequest} (function-code-tagged)
+ * holders rather than raw futures; this reflects the holder in the
+ * same way production code does (byte functionFlag, CompletableFuture
future).
+ */
+ private static Object newPendingRequest(byte functionFlag,
CompletableFuture<ModbusRtuADU> future) throws Exception {
+ Class<?> pendingRequestClass =
Arrays.stream(ModbusRtuConnection.class.getDeclaredClasses())
+ .filter(c -> c.getSimpleName().equals("PendingRequest"))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("PendingRequest
nested class not found"));
+ Constructor<?> constructor =
pendingRequestClass.getDeclaredConstructor(byte.class, CompletableFuture.class);
+ constructor.setAccessible(true);
+ return constructor.newInstance(functionFlag, future);
+ }
+
@Test
void testHandleIncomingMessage() throws Exception {
Method method =
ModbusRtuConnection.class.getDeclaredMethod("handleIncomingMessage",
ModbusRtuADU.class);
@@ -219,11 +235,10 @@ class ModbusRtuConnectionTest {
Field pendingField =
ModbusRtuConnection.class.getDeclaredField("pendingRequests");
pendingField.setAccessible(true);
@SuppressWarnings("unchecked")
- Map<Short, CompletableFuture<ModbusRtuADU>> pendingRequests =
- (Map<Short, CompletableFuture<ModbusRtuADU>>)
pendingField.get(connection);
+ Map<Short, Object> pendingRequests = (Map<Short, Object>)
pendingField.get(connection);
CompletableFuture<ModbusRtuADU> future = new CompletableFuture<>();
- pendingRequests.put((short) 1, future);
+ pendingRequests.put((short) 1, newPendingRequest((byte) 0x03, future));
ModbusRtuADU responseAdu = new ModbusRtuADU((short) 1,
new ModbusPDUReadHoldingRegistersResponse(new byte[]{0x00, 0x2A}));
@@ -248,11 +263,10 @@ class ModbusRtuConnectionTest {
Field pendingField =
ModbusRtuConnection.class.getDeclaredField("pendingRequests");
pendingField.setAccessible(true);
@SuppressWarnings("unchecked")
- Map<Short, CompletableFuture<ModbusRtuADU>> pendingRequests =
- (Map<Short, CompletableFuture<ModbusRtuADU>>)
pendingField.get(connection);
+ Map<Short, Object> pendingRequests = (Map<Short, Object>)
pendingField.get(connection);
CompletableFuture<ModbusRtuADU> future = new CompletableFuture<>();
- pendingRequests.put((short) 1, future);
+ pendingRequests.put((short) 1, newPendingRequest((byte) 0x03, future));
connection.close();
@@ -265,11 +279,10 @@ class ModbusRtuConnectionTest {
Field pendingField =
ModbusRtuConnection.class.getDeclaredField("pendingRequests");
pendingField.setAccessible(true);
@SuppressWarnings("unchecked")
- Map<Short, CompletableFuture<ModbusRtuADU>> pendingRequests =
- (Map<Short, CompletableFuture<ModbusRtuADU>>)
pendingField.get(connection);
+ Map<Short, Object> pendingRequests = (Map<Short, Object>)
pendingField.get(connection);
CompletableFuture<ModbusRtuADU> future = new CompletableFuture<>();
- pendingRequests.put((short) 1, future);
+ pendingRequests.put((short) 1, newPendingRequest((byte) 0x03, future));
Method method =
ModbusRtuConnection.class.getDeclaredMethod("onTransportDisconnected",
Throwable.class);
method.setAccessible(true);
diff --git
a/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/SerialTransportInstance.java
b/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/SerialTransportInstance.java
index 00e2bf2be8..7f951fced3 100644
---
a/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/SerialTransportInstance.java
+++
b/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/SerialTransportInstance.java
@@ -54,6 +54,7 @@ public class SerialTransportInstance extends
BaseTransportInstance<SerialTranspo
// subscriber rings (64 KiB) so burst tolerance is consistent.
private static final int DEFAULT_BUFFER_SIZE = 65536;
private static final byte[] EMPTY_BYTES = new byte[0];
+ private static final java.util.concurrent.atomic.AtomicLong
DISPATCH_THREAD_COUNTER = new java.util.concurrent.atomic.AtomicLong();
private final SharedSerialPortManager sharedSerialPortManager;
private final SerialPort port;
@@ -188,7 +189,7 @@ public class SerialTransportInstance extends
BaseTransportInstance<SerialTranspo
};
tempSharedPort.addSubscriber(this.sharedSubscriber);
this.sharedDispatchExecutor =
Executors.newSingleThreadExecutor(runnable -> {
- Thread thread = new Thread(runnable,
"Serial-Shared-Dispatch-" + port);
+ Thread thread = new Thread(runnable,
"Serial-Shared-Dispatch-" + port + "-" +
DISPATCH_THREAD_COUNTER.incrementAndGet());
thread.setDaemon(true);
return thread;
});
diff --git
a/plc4j/transports/serial/src/test/java/org/apache/plc4x/java/transport/serial/SharedModeInstanceTest.java
b/plc4j/transports/serial/src/test/java/org/apache/plc4x/java/transport/serial/SharedModeInstanceTest.java
index 2d31f8f424..a5723d6981 100644
---
a/plc4j/transports/serial/src/test/java/org/apache/plc4x/java/transport/serial/SharedModeInstanceTest.java
+++
b/plc4j/transports/serial/src/test/java/org/apache/plc4x/java/transport/serial/SharedModeInstanceTest.java
@@ -185,6 +185,35 @@ class SharedModeInstanceTest {
verify(mockPort).closePort();
}
+ @Test
+ void eachSharedInstanceGetsAUniquelyNamedDispatchThread() throws Exception
{
+ MockPortFactory factory = new MockPortFactory();
+ SharedSerialPortManager manager = new SharedSerialPortManager(factory);
+
+ SerialTransportInstance first = new SerialTransportInstance(
+ manager, "COMSHARED", config(), AuditLog.builder().build());
+ SerialTransportInstance second = new SerialTransportInstance(
+ manager, "COMSHARED", config(), AuditLog.builder().build());
+
+ try {
+ AtomicReference<String> firstThread = new AtomicReference<>();
+ AtomicReference<String> secondThread = new AtomicReference<>();
+ first.registerDataListener(() -> firstThread.compareAndSet(null,
Thread.currentThread().getName()));
+ second.registerDataListener(() -> secondThread.compareAndSet(null,
Thread.currentThread().getName()));
+
+ factory.broadcast(new byte[]{0x01, 0x02, 0x03, 0x04});
+ awaitTrue(() -> firstThread.get() != null && secondThread.get() !=
null, 5, TimeUnit.SECONDS);
+
+
assertTrue(firstThread.get().startsWith("Serial-Shared-Dispatch-"),
firstThread.get());
+
assertTrue(secondThread.get().startsWith("Serial-Shared-Dispatch-"),
secondThread.get());
+ assertNotEquals(firstThread.get(), secondThread.get(),
+ "two instances on one port must have distinct dispatch
threads");
+ } finally {
+ first.close();
+ second.close();
+ }
+ }
+
/**
* Regression test for shared-mode dispatch isolation: the shared reader
* thread iterates all subscribers' onData() inline
(SharedPort.readFromPort()).