Problem
tcpTransporter.Send's readResultCloseRetry branch closes the connection and goes straight back round the loop with no delay:
case readResultCloseRetry:
mb.logf("modbus: close connection and retry reading response, because of %v", err)
mb.close()
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
continue
}
Each iteration is a full TCP dial plus a request write plus a read. Against a device that accepts a connection and drops it without answering, this spins as fast as the network allows for the whole LinkRecoveryTimeout budget.
Measured with a server that reads the request and closes immediately, LinkRecoveryTimeout = 500ms:
transaction IDs on the wire: 1 (x 15726 within the 500ms budget)
unique ids: ['1']
~30k dials/second at a single embedded Modbus server. These are small microcontrollers — that is enough to wedge one, and the reconnect storm can outlast the condition that triggered it.
This is also the behaviour the spec singles out. MODBUS Messaging on TCP/IP Implementation Guide V1.0b §4.4.1.4:
any 'timeout' time used at a client to initiate an application retry should be larger than the expected maximum 'reasonable' response time. If this is not followed, there is a potential for excessive congestion at the target device or on the network, which may in turn cause further errors. This is a characteristic, which should always be avoided.
The serial transporter already solves this
ef3bed5 ("Add retry interval for serial connections to avoid link flapping") gave serialPort.reconnect() a ticker-paced retry loop:
ReconnectRetryInterval time.Duration on the transporter (serial.go:40)
serialReconnectRetryInterval = 10 * time.Millisecond as the default (serial.go:23)
reconnectRetryInterval() accessor falling back to the default (serial.go:141-144)
- a
time.NewTicker(...) the reconnect loop selects on, alongside the deadline and ctx.Done()
The TCP path never got the equivalent, so the two transports behave very differently under the same fault.
Suggested fix
Port that pattern to tcpTransporter: add ReconnectRetryInterval with a default, and have the readResultCloseRetry branch select on a ticker as well as ctx.Done(). Keeping the default small (the serial one is 10 ms) preserves fast recovery for the case link recovery exists for, while bounding the loop to something a device can absorb.
Context
Found while reviewing #133, which removed a different duplicate-request path in the same function and gated this branch so that writes are no longer reissued here. Reads still are, so the tight loop remains reachable. Not fixed there to keep that PR to one behavioural change.
Problem
tcpTransporter.Send'sreadResultCloseRetrybranch closes the connection and goes straight back round the loop with no delay:Each iteration is a full TCP dial plus a request write plus a read. Against a device that accepts a connection and drops it without answering, this spins as fast as the network allows for the whole
LinkRecoveryTimeoutbudget.Measured with a server that reads the request and closes immediately,
LinkRecoveryTimeout = 500ms:~30k dials/second at a single embedded Modbus server. These are small microcontrollers — that is enough to wedge one, and the reconnect storm can outlast the condition that triggered it.
This is also the behaviour the spec singles out. MODBUS Messaging on TCP/IP Implementation Guide V1.0b §4.4.1.4:
The serial transporter already solves this
ef3bed5("Add retry interval for serial connections to avoid link flapping") gaveserialPort.reconnect()a ticker-paced retry loop:ReconnectRetryInterval time.Durationon the transporter (serial.go:40)serialReconnectRetryInterval = 10 * time.Millisecondas the default (serial.go:23)reconnectRetryInterval()accessor falling back to the default (serial.go:141-144)time.NewTicker(...)the reconnect loop selects on, alongside the deadline andctx.Done()The TCP path never got the equivalent, so the two transports behave very differently under the same fault.
Suggested fix
Port that pattern to
tcpTransporter: addReconnectRetryIntervalwith a default, and have thereadResultCloseRetrybranch select on a ticker as well asctx.Done(). Keeping the default small (the serial one is 10 ms) preserves fast recovery for the case link recovery exists for, while bounding the loop to something a device can absorb.Context
Found while reviewing #133, which removed a different duplicate-request path in the same function and gated this branch so that writes are no longer reissued here. Reads still are, so the tight loop remains reachable. Not fixed there to keep that PR to one behavioural change.