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 5238b33c61 fix(plc4go): make UDP TransportInstance.Reset a no-op 
(lease-time Reset raced the receive worker and ate inbound datagrams)
5238b33c61 is described below

commit 5238b33c611e751f0e50e02f83a8db001fdfe282
Author: Sebastian Rühl <[email protected]>
AuthorDate: Wed Jul 15 14:55:15 2026 +0200

    fix(plc4go): make UDP TransportInstance.Reset a no-op (lease-time Reset 
raced the receive worker and ate inbound datagrams)
    
    Reset() poked the read deadline, drained one datagram into a discard
    buffer, and swapped m.reader - all unsynchronized, and all invoked from
    outside the receive worker: the connection cache runs Reset on every
    successful lease, and DefaultCodec runs it on retryable errors. The
    receive worker concurrently drives GetNumBytesAvailableInBuffer/Peek on
    the same socket and bufio.Reader, so the drain could eat a fresh
    datagram off the socket and the reader swap could throw away bytes the
    worker had just buffered.
    
    Field evidence (BACnet): a WriteProperty SimpleAck reached the host
    interface with a good checksum 0.8ms after the request, was never
    surfaced by the codec, and the write hung until its expectation
    expired. The new roundtrip tests reproduce the exact production shape -
    a no-TTL connection cache re-leasing the same live connection (Reset on
    a live socket under an active 10ms receive poll) - and fail under
    -race against the old Reset.
    
    For UDP nothing needs resetting: datagram framing means stale buffered
    data is just a complete late reply, which expectation matching already
    drops. Reconnection semantics stay in Close/Connect. The TCP transport
    has the same unlocked pattern but stream framing makes a plain no-op
    wrong there; that remains open.
---
 .../bacnetip/LeaseResetWriteRoundtrip_test.go      | 137 +++++++++++++++++++++
 plc4go/spi/transports/udp/TransportInstance.go     |  25 ++--
 2 files changed, 154 insertions(+), 8 deletions(-)

diff --git a/plc4go/internal/bacnetip/LeaseResetWriteRoundtrip_test.go 
b/plc4go/internal/bacnetip/LeaseResetWriteRoundtrip_test.go
new file mode 100644
index 0000000000..c1cce86325
--- /dev/null
+++ b/plc4go/internal/bacnetip/LeaseResetWriteRoundtrip_test.go
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://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 bacnetip
+
+import (
+       "context"
+       "fmt"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       plc4go "github.com/apache/plc4x/plc4go/pkg/api"
+       "github.com/apache/plc4x/plc4go/pkg/api/cache"
+       apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
+       apiTransports "github.com/apache/plc4x/plc4go/pkg/api/transports"
+       "github.com/apache/plc4x/plc4go/spi/options"
+       "github.com/apache/plc4x/plc4go/spi/testutils"
+)
+
+// TestNativeBacnetWrite_LeaseResetOnLiveSocket covers the one cache path the
+// earlier roundtrip tests missed: a cache WITHOUT maxIdleTime (the production
+// default) re-leases the SAME live connection, and every successful lease runs
+// TransportInstance.Reset() — deadline poke, drain read, bufio.Reader swap —
+// concurrently with the codec's 10ms receive-poll worker on the same socket.
+// The write's SimpleAck (arriving ~1ms after TX, like the field pcap) must
+// still resolve Execute on every iteration.
+//
+// Field shape (CI run 29414550340): read lease, return, idle gap, write lease
+// (Reset on live socket), TX +300ms, ack +0.8ms — ack visible on the host
+// interface with a good checksum but never surfaced by the codec.
+func TestNativeBacnetWrite_LeaseResetOnLiveSocket(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startFakeWriteDevice(t, log)
+       t.Cleanup(device.stop)
+
+       dm := plc4go.NewPlcDriverManager(options.WithCustomLogger(log))
+       dm.RegisterDriver(NewDriver(options.WithCustomLogger(log)))
+       apiTransports.RegisterUdpTransport(dm)
+       // No WithMaxIdleTime: the connection is never recycled, so every lease
+       // after the first resets the LIVE transport instead of reconnecting.
+       connCache := cache.NewPlcConnectionCache(dm, 
cache.WithCustomLogger(log))
+       t.Cleanup(func() { assert.NoError(t, connCache.Close()) })
+
+       connStr := 
fmt.Sprintf("bacnet-ip:udp://127.0.0.1:%d?local-port=0&ApduTimeoutMs=3000", 
device.port())
+
+       // Lease #1: priming read (creates the connection; scan analogue).
+       {
+               ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+               conn, err := connCache.GetConnection(ctx, connStr)
+               cancel()
+               require.NoError(t, err)
+               code, val, ok := readPresentValue(t, conn)
+               require.True(t, ok, "priming read via cache hung")
+               assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+               assert.InDelta(t, 23.5, val, 0.001)
+               require.NoError(t, conn.Close())
+       }
+
+       // Short idle, then many write leases. Each lease runs Reset() against 
the
+       // live socket while the receive worker polls — every iteration is one
+       // spin of the race wheel.
+       time.Sleep(500 * time.Millisecond)
+       for i := 0; i < 50; i++ {
+               ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+               conn, err := connCache.GetConnection(ctx, connStr)
+               cancel()
+               require.NoError(t, err, "lease %d failed", i)
+               code, ok := writePresentValue(t, conn)
+               require.NoError(t, conn.Close())
+               require.True(t, ok, "iteration %d: write did not complete 
(Execute never resolved) — SimpleAck lost around lease-time Reset", i)
+               assert.Equal(t, apiModel.PlcResponseCode_OK, code, "iteration 
%d", i)
+       }
+}
+
+// TestNativeBacnetWrite_LeaseResetWithIdleGap is the exact field ordering with
+// a real idle gap per iteration: read, return, idle >1s, write lease (Reset on
+// live socket), delayed device reply. Slower per iteration but closest to the
+// failing CI run's shape.
+func TestNativeBacnetWrite_LeaseResetWithIdleGap(t *testing.T) {
+       if testing.Short() {
+               t.Skip("multi-second idle gaps")
+       }
+       log := testutils.ProduceTestingLogger(t)
+       device := startDelayedWriteDevice(t, log, 300*time.Millisecond)
+       t.Cleanup(device.stop)
+
+       dm := plc4go.NewPlcDriverManager(options.WithCustomLogger(log))
+       dm.RegisterDriver(NewDriver(options.WithCustomLogger(log)))
+       apiTransports.RegisterUdpTransport(dm)
+       connCache := cache.NewPlcConnectionCache(dm, 
cache.WithCustomLogger(log))
+       t.Cleanup(func() { assert.NoError(t, connCache.Close()) })
+
+       connStr := 
fmt.Sprintf("bacnet-ip:udp://127.0.0.1:%d?local-port=0&ApduTimeoutMs=5000", 
device.port())
+
+       {
+               ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+               conn, err := connCache.GetConnection(ctx, connStr)
+               cancel()
+               require.NoError(t, err)
+               code, val, ok := readPresentValue(t, conn)
+               require.True(t, ok, "priming read via cache hung")
+               assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+               assert.InDelta(t, 23.5, val, 0.001)
+               require.NoError(t, conn.Close())
+       }
+
+       for i := 0; i < 3; i++ {
+               time.Sleep(1500 * time.Millisecond) // idle gap on the returned 
lease
+               ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
+               conn, err := connCache.GetConnection(ctx, connStr)
+               cancel()
+               require.NoError(t, err, "lease %d failed", i)
+               code, ok := writePresentValue(t, conn)
+               require.NoError(t, conn.Close())
+               require.True(t, ok, "iteration %d: write after idle gap did not 
complete", i)
+               assert.Equal(t, apiModel.PlcResponseCode_OK, code, "iteration 
%d", i)
+       }
+}
diff --git a/plc4go/spi/transports/udp/TransportInstance.go 
b/plc4go/spi/transports/udp/TransportInstance.go
index f6a0f594da..4634d6daa7 100644
--- a/plc4go/spi/transports/udp/TransportInstance.go
+++ b/plc4go/spi/transports/udp/TransportInstance.go
@@ -123,15 +123,24 @@ func (m *TransportInstance) Connect(ctx context.Context) 
error {
        return nil
 }
 
+// Reset is deliberately a no-op for UDP. It used to poke the read deadline,
+// drain one datagram into a discard buffer, and swap m.reader — but it is
+// called from OUTSIDE the receive worker (connection-cache lease grants,
+// DefaultCodec retryable-error handling) while the worker concurrently drives
+// GetNumBytesAvailableInBuffer/Peek on the very same socket and reader. Every
+// one of those mutations races the worker and can silently destroy a FRESH
+// inbound datagram: the drain read eats it off the socket, and the reader
+// swap throws away bytes the worker had just buffered (observed in the field
+// as a BACnet WriteProperty SimpleAck that reached the host interface with a
+// good checksum but never surfaced; reproduced by
+// bacnetip.TestNativeBacnetWrite_LeaseResetOnLiveSocket).
+//
+// Nothing here needs cleaning anyway: UDP is datagram-framed, so "stale"
+// buffered data is just a complete late reply, which the codec's expectation
+// matching already drops without harm. Reconnection semantics live in
+// Close/Connect.
 func (m *TransportInstance) Reset() {
-       if m.udpConn == nil {
-               m.log.Trace().Msg("No connection to reset")
-               return
-       }
-       _ = m.udpConn.SetReadDeadline(time.Now().Add(1))
-       _, _, _ = m.udpConn.ReadFromUDP(make([]byte, 4096))
-       m.reader = bufio.NewReader(m.udpConn)
-       m.log.Trace().Msg("Connection reset")
+       m.log.Trace().Msg("Reset is a no-op for UDP (see doc comment)")
 }
 
 func (m *TransportInstance) Close() error {

Reply via email to