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 24daaca625 test(plc4go): pin BACnet write SimpleAck handling across 
codec, driver, and UDP transport
24daaca625 is described below

commit 24daaca625e85d7df4cf6d28d27918f42c2b5397
Author: Sebastian Rühl <[email protected]>
AuthorDate: Tue Jul 14 14:57:16 2026 +0200

    test(plc4go): pin BACnet write SimpleAck handling across codec, driver, and 
UDP transport
    
    Regression tests from a ugagent write-hang investigation (three rounds) that
    exonerated plc4go at every layer:
    
    - WriteRoundtrip_test.go: codec-level — a wire-shaped SimpleAck (matching
      invoke id + service choice) resolves WriteProperty Execute with OK; wrong
      invoke id does not resolve; Error-PDU maps to ACCESS_DENIED.
    - IdleWriteRoundtrip_test.go: driver-level — writes resolve after idle gaps
      and with delayed replies (cache idle-reconnect included).
    - DelayedReply_test.go: UDP transport — the per-poll receive deadline is
      re-armed across arbitrary idle periods; delayed datagrams still reach the
      codec.
    
    Left for a future change (identified, not fixed here): TransportInstance
    Reset() runs unlocked on every lease and can data-race the receive worker.
---
 .../internal/bacnetip/IdleWriteRoundtrip_test.go   | 204 +++++++++++++++
 plc4go/internal/bacnetip/WriteRoundtrip_test.go    | 281 +++++++++++++++++++++
 plc4go/spi/transports/udp/DelayedReply_test.go     | 112 ++++++++
 3 files changed, 597 insertions(+)

diff --git a/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go 
b/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go
new file mode 100644
index 0000000000..07f8d8007a
--- /dev/null
+++ b/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go
@@ -0,0 +1,204 @@
+/*
+ * 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"
+       "net"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/rs/zerolog"
+       "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"
+       model 
"github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model"
+       "github.com/apache/plc4x/plc4go/spi/options"
+       "github.com/apache/plc4x/plc4go/spi/testutils"
+)
+
+// delayedWriteDevice answers WriteProperty with a SimpleAck after a fixed 
delay,
+// mimicking the ~300ms round-trip observed in the field (instant replies in 
the
+// other fakes cannot exercise the delayed-arrival path).
+type delayedWriteDevice struct {
+       conn  *net.UDPConn
+       wg    sync.WaitGroup
+       log   zerolog.Logger
+       delay time.Duration
+       ctx   context.Context
+}
+
+func startDelayedWriteDevice(t *testing.T, log zerolog.Logger, delay 
time.Duration) *delayedWriteDevice {
+       t.Helper()
+       conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 
1), Port: 0})
+       require.NoError(t, err)
+       d := &delayedWriteDevice{conn: conn, log: log, delay: delay, ctx: 
t.Context()}
+       d.wg.Add(1)
+       go d.serve()
+       return d
+}
+
+func (d *delayedWriteDevice) port() int { return 
d.conn.LocalAddr().(*net.UDPAddr).Port }
+func (d *delayedWriteDevice) stop()     { _ = d.conn.Close(); d.wg.Wait() }
+
+func (d *delayedWriteDevice) serve() {
+       defer d.wg.Done()
+       buf := make([]byte, 4096)
+       for {
+               n, src, err := d.conn.ReadFromUDP(buf)
+               if err != nil {
+                       return
+               }
+               data := make([]byte, n)
+               copy(data, buf[:n])
+               bvlc, err := model.BVLCParse[model.BVLC](d.ctx, data)
+               if err != nil {
+                       continue
+               }
+               npduRetriever, ok := bvlc.(interface{ GetNpdu() model.NPDU })
+               if !ok {
+                       continue
+               }
+               cr, ok := 
npduRetriever.GetNpdu().GetApdu().(model.APDUConfirmedRequest)
+               if !ok {
+                       continue
+               }
+               invokeId := cr.GetInvokeId()
+               choice := cr.GetServiceRequest().GetServiceChoice()
+               go func() {
+                       time.Sleep(d.delay)
+                       var resp model.BVLC
+                       if choice == 
model.BACnetConfirmedServiceChoice_READ_PROPERTY {
+                               resp = buildReadAckFor(invokeId)
+                       } else {
+                               resp = 
wrapAPDU(model.NewAPDUSimpleAck(invokeId, choice), false)
+                       }
+                       theBytes, err := resp.Serialize()
+                       if err != nil {
+                               return
+                       }
+                       _, _ = d.conn.WriteToUDP(theBytes, src)
+               }()
+       }
+}
+
+// TestNativeBacnetWrite_DelayedReplyAfterIdle combines both field conditions: 
a
+// multi-second idle gap AND a ~400ms device reply delay on the write.
+func TestNativeBacnetWrite_DelayedReplyAfterIdle(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startDelayedWriteDevice(t, log, 400*time.Millisecond)
+       t.Cleanup(device.stop)
+
+       conn := newWriteTestConnection(t, log, device.port())
+
+       code, val, ok := readPresentValue(t, conn)
+       require.True(t, ok, "priming read hung")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+       assert.InDelta(t, 23.5, val, 0.001)
+
+       time.Sleep(3 * time.Second)
+
+       code, ok = writePresentValue(t, conn)
+       require.True(t, ok, "delayed write after idle gap did not resolve")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+}
+
+// TestNativeBacnetWrite_AfterIdleGap reproduces the real field ordering: a 
read
+// succeeds, then the connection sits idle for several seconds (the receive 
loop
+// keeps polling with expiring read deadlines), then a WriteProperty is issued.
+// The SimpleAck must still resolve the Execute channel.
+func TestNativeBacnetWrite_AfterIdleGap(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startFakeWriteDevice(t, log)
+       t.Cleanup(device.stop)
+
+       conn := newWriteTestConnection(t, log, device.port())
+
+       // One read to prime the connection (mirrors discovery/poll).
+       code, val, ok := readPresentValue(t, conn)
+       require.True(t, ok, "priming read hung")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+       assert.InDelta(t, 23.5, val, 0.001)
+
+       // Idle gap well beyond the 10ms per-poll read deadline.
+       time.Sleep(3 * time.Second)
+
+       code, ok = writePresentValue(t, conn)
+       t.Logf("device received %d request(s)", device.requestCount())
+       require.True(t, ok, "write after idle gap did not complete (Execute 
never resolved)")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+}
+
+// TestNativeBacnetWrite_AfterIdleViaCache mirrors the real ugagent path: the
+// connection is leased from a PlcConnectionCache with a short max-idle-time, a
+// read runs, the lease is returned and sits idle past maxIdleTime (so the 
cache
+// proactively reconnects on the next lease, opening a NEW ephemeral socket),
+// then a write is issued. The SimpleAck must still resolve.
+func TestNativeBacnetWrite_AfterIdleViaCache(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)
+       connCache := cache.NewPlcConnectionCache(dm,
+               cache.WithCustomLogger(log),
+               cache.WithMaxIdleTime(500*time.Millisecond),
+       )
+       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: a read.
+       {
+               ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+               defer cancel()
+               conn, err := connCache.GetConnection(ctx, connStr)
+               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()) // return lease to cache -> 
becomes idle
+       }
+
+       // Idle past maxIdleTime so the next lease triggers a reconnect (new 
socket).
+       time.Sleep(2 * time.Second)
+
+       // Lease #2: a write on the (reconnected) connection.
+       {
+               ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+               defer cancel()
+               conn, err := connCache.GetConnection(ctx, connStr)
+               require.NoError(t, err)
+               code, ok := writePresentValue(t, conn)
+               t.Logf("device received %d request(s)", device.requestCount())
+               require.NoError(t, conn.Close())
+               require.True(t, ok, "write after idle-reconnect via cache did 
not complete (Execute never resolved)")
+               assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+       }
+}
diff --git a/plc4go/internal/bacnetip/WriteRoundtrip_test.go 
b/plc4go/internal/bacnetip/WriteRoundtrip_test.go
new file mode 100644
index 0000000000..07c4828a3d
--- /dev/null
+++ b/plc4go/internal/bacnetip/WriteRoundtrip_test.go
@@ -0,0 +1,281 @@
+/*
+ * 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"
+       "net"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/rs/zerolog"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       plc4go "github.com/apache/plc4x/plc4go/pkg/api"
+       apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
+       apiTransports "github.com/apache/plc4x/plc4go/pkg/api/transports"
+       model 
"github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model"
+       "github.com/apache/plc4x/plc4go/spi/options"
+       "github.com/apache/plc4x/plc4go/spi/testutils"
+)
+
+// fakeWriteDevice is a minimal UDP BACnet/IP device that answers every
+// confirmed WriteProperty / WritePropertyMultiple request with the 
spec-correct
+// success response: a SimpleAck (clause 15.9) echoing the request's invoke id
+// and service choice.
+type fakeWriteDevice struct {
+       conn    *net.UDPConn
+       wg      sync.WaitGroup
+       log     zerolog.Logger
+       gotReqs int
+       ctx     context.Context
+       mu      sync.Mutex
+
+       // wrongInvokeId, when true, makes the device reply with an off-by-one
+       // invoke id so we can assert the client does NOT accept the mismatch.
+       wrongInvokeId bool
+       // replyError, when true, makes the device reply with a BACnet Error-PDU
+       // (WRITE_ACCESS_DENIED) instead of a SimpleAck.
+       replyError bool
+}
+
+func startFakeWriteDevice(t *testing.T, log zerolog.Logger) *fakeWriteDevice {
+       t.Helper()
+       addr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}
+       conn, err := net.ListenUDP("udp4", addr)
+       require.NoError(t, err)
+       d := &fakeWriteDevice{conn: conn, log: log, ctx: t.Context()}
+       d.wg.Add(1)
+       go d.serve()
+       return d
+}
+
+func (d *fakeWriteDevice) port() int { return 
d.conn.LocalAddr().(*net.UDPAddr).Port }
+
+func (d *fakeWriteDevice) requestCount() int {
+       d.mu.Lock()
+       defer d.mu.Unlock()
+       return d.gotReqs
+}
+
+func (d *fakeWriteDevice) serve() {
+       defer d.wg.Done()
+       buf := make([]byte, 4096)
+       for {
+               n, src, err := d.conn.ReadFromUDP(buf)
+               if err != nil {
+                       return // socket closed
+               }
+               data := make([]byte, n)
+               copy(data, buf[:n])
+
+               invokeId, choice, ok := d.extractInvokeIdAndChoice(data)
+               if !ok {
+                       d.log.Warn().Msg("fake write device: could not extract 
invoke id; ignoring")
+                       continue
+               }
+               d.mu.Lock()
+               d.gotReqs++
+               d.mu.Unlock()
+
+               replyInvokeId := invokeId
+               if d.wrongInvokeId {
+                       replyInvokeId = invokeId + 1
+               }
+
+               var resp model.BVLC
+               switch {
+               case choice == model.BACnetConfirmedServiceChoice_READ_PROPERTY:
+                       resp = buildReadAckFor(replyInvokeId)
+               case d.replyError:
+                       resp = d.buildErrorReply(replyInvokeId, choice)
+               default:
+                       resp = d.buildSimpleAck(replyInvokeId, choice)
+               }
+               theBytes, err := resp.Serialize()
+               if err != nil {
+                       d.log.Error().Err(err).Msg("fake write device: 
serialize response")
+                       continue
+               }
+               if _, err := d.conn.WriteToUDP(theBytes, src); err != nil {
+                       d.log.Error().Err(err).Msg("fake write device: write 
response")
+                       continue
+               }
+               d.log.Info().Uint8("invokeId", replyInvokeId).Int("bytes", 
len(theBytes)).Stringer("dst", src).Msg("fake write device sent reply")
+       }
+}
+
+func (d *fakeWriteDevice) extractInvokeIdAndChoice(data []byte) (uint8, 
model.BACnetConfirmedServiceChoice, bool) {
+       bvlc, err := model.BVLCParse[model.BVLC](d.ctx, data)
+       if err != nil {
+               d.log.Error().Err(err).Msg("fake write device: parse BVLC")
+               return 0, 0, false
+       }
+       npduRetriever, ok := bvlc.(interface{ GetNpdu() model.NPDU })
+       if !ok {
+               return 0, 0, false
+       }
+       apdu := npduRetriever.GetNpdu().GetApdu()
+       cr, ok := apdu.(model.APDUConfirmedRequest)
+       if !ok {
+               d.log.Warn().Msgf("fake write device: not a confirmed request: 
%T", apdu)
+               return 0, 0, false
+       }
+       return cr.GetInvokeId(), cr.GetServiceRequest().GetServiceChoice(), true
+}
+
+func (d *fakeWriteDevice) buildSimpleAck(invokeId uint8, choice 
model.BACnetConfirmedServiceChoice) model.BVLC {
+       apdu := model.NewAPDUSimpleAck(invokeId, choice)
+       return wrapAPDU(apdu, false)
+}
+
+// buildReadAckFor mirrors fakeBacnetDevice.buildReadPropertyAck: a 
ReadProperty
+// ComplexAck for ANALOG_INPUT,1/PRESENT_VALUE echoing the invoke id.
+func buildReadAckFor(invokeId uint8) model.BVLC {
+       serviceAck := model.NewBACnetServiceAckReadProperty(
+               0,
+               model.CreateBACnetContextTagObjectIdentifier(0, 
uint16(model.BACnetObjectType_ANALOG_INPUT), 1),
+               model.CreateBACnetPropertyIdentifierTagged(1, 
uint32(model.BACnetPropertyIdentifier_PRESENT_VALUE)),
+               nil,
+               
constructedDataFromTag(model.CreateBACnetApplicationTagReal(23.5)),
+       )
+       apdu := model.NewAPDUComplexAck(false, false, invokeId, nil, nil, 
serviceAck, nil, nil)
+       return wrapAPDU(apdu, false)
+}
+
+func (d *fakeWriteDevice) buildErrorReply(invokeId uint8, choice 
model.BACnetConfirmedServiceChoice) model.BVLC {
+       base := buildErrorAPDU(model.ErrorClass_PROPERTY, 
model.ErrorCode_WRITE_ACCESS_DENIED)
+       apdu := model.NewAPDUError(invokeId, choice, base.GetError())
+       return wrapAPDU(apdu, false)
+}
+
+func (d *fakeWriteDevice) stop() {
+       _ = d.conn.Close()
+       d.wg.Wait()
+}
+
+// writePresentValue issues a single WriteRequest for 
ANALOG_VALUE,1/PRESENT_VALUE
+// against the given connection and returns the response code. The bool reports
+// whether the Execute channel resolved at all (false == timed out / hung).
+func writePresentValue(t *testing.T, conn plc4go.PlcConnection) 
(apiModel.PlcResponseCode, bool) {
+       t.Helper()
+       wr, err := conn.WriteRequestBuilder().
+               AddTagAddress("pv", "ANALOG_VALUE,1/PRESENT_VALUE", 
float32(75.0)).
+               Build()
+       require.NoError(t, err)
+       ctx, cancel := context.WithTimeout(t.Context(), 6*time.Second)
+       t.Cleanup(cancel)
+       select {
+       case <-ctx.Done():
+               return 0, false
+       case res := <-wr.Execute(ctx):
+               if res.GetErr() != nil {
+                       t.Logf("write error: %v", res.GetErr())
+                       return 0, false
+               }
+               return res.GetResponse().GetResponseCode("pv"), true
+       }
+}
+
+func newWriteTestConnection(t *testing.T, log zerolog.Logger, port int) 
plc4go.PlcConnection {
+       t.Helper()
+       dm := plc4go.NewPlcDriverManager(options.WithCustomLogger(log))
+       dm.RegisterDriver(NewDriver(options.WithCustomLogger(log)))
+       apiTransports.RegisterUdpTransport(dm)
+
+       connStr := 
fmt.Sprintf("bacnet-ip:udp://127.0.0.1:%d?local-port=0&ApduTimeoutMs=3000", 
port)
+       ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+       t.Cleanup(cancel)
+       conn, err := dm.GetConnection(ctx, connStr)
+       require.NoError(t, err)
+       t.Cleanup(func() { assert.NoError(t, conn.Close()) })
+       return conn
+}
+
+// TestNativeBacnetWrite_SimpleAckResolves is the core regression: a 
WriteProperty
+// answered by a spec-correct SimpleAck must resolve the Execute channel with 
OK.
+func TestNativeBacnetWrite_SimpleAckResolves(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startFakeWriteDevice(t, log)
+       t.Cleanup(device.stop)
+
+       conn := newWriteTestConnection(t, log, device.port())
+       code, ok := writePresentValue(t, conn)
+       t.Logf("device received %d request(s)", device.requestCount())
+       require.True(t, ok, "write did not complete (Execute never resolved — 
hung on SimpleAck)")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+}
+
+// TestNativeBacnetWrite_WrongInvokeIdDoesNotResolve confirms a SimpleAck 
bearing
+// the wrong invoke id is NOT accepted (the request must hang / time out rather
+// than falsely report success).
+func TestNativeBacnetWrite_WrongInvokeIdDoesNotResolve(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startFakeWriteDevice(t, log)
+       device.wrongInvokeId = true
+       t.Cleanup(device.stop)
+
+       conn := newWriteTestConnection(t, log, device.port())
+       _, ok := writePresentValue(t, conn)
+       assert.False(t, ok, "a SimpleAck with the wrong invoke id must NOT 
resolve the write")
+}
+
+// TestNativeBacnetWrite_AfterReads reproduces the real E2E ordering: the same
+// connection is used for several ReadProperty requests first (advancing the
+// shared invoke-id generator so the write lands on a non-zero invoke id, and
+// exercising the idle period between requests), then a WriteProperty is issued
+// on that same connection. The write must resolve with OK.
+func TestNativeBacnetWrite_AfterReads(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startFakeWriteDevice(t, log)
+       t.Cleanup(device.stop)
+
+       conn := newWriteTestConnection(t, log, device.port())
+
+       // Several reads first (mirrors discovery) so the write uses a later 
invoke id.
+       for i := 0; i < 6; i++ {
+               code, val, ok := readPresentValue(t, conn)
+               require.True(t, ok, "read %d hung", i)
+               assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+               assert.InDelta(t, 23.5, val, 0.001)
+       }
+
+       code, ok := writePresentValue(t, conn)
+       t.Logf("device received %d request(s)", device.requestCount())
+       require.True(t, ok, "write after reads did not complete (Execute never 
resolved)")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+}
+
+// TestNativeBacnetWrite_ErrorPduResolvesWithCode confirms a BACnet Error-PDU 
for
+// the write resolves the request with the mapped error code.
+func TestNativeBacnetWrite_ErrorPduResolvesWithCode(t *testing.T) {
+       log := testutils.ProduceTestingLogger(t)
+       device := startFakeWriteDevice(t, log)
+       device.replyError = true
+       t.Cleanup(device.stop)
+
+       conn := newWriteTestConnection(t, log, device.port())
+       code, ok := writePresentValue(t, conn)
+       require.True(t, ok, "an Error-PDU must resolve the write (not hang)")
+       assert.Equal(t, apiModel.PlcResponseCode_ACCESS_DENIED, code)
+}
diff --git a/plc4go/spi/transports/udp/DelayedReply_test.go 
b/plc4go/spi/transports/udp/DelayedReply_test.go
new file mode 100644
index 0000000000..1413ee07d5
--- /dev/null
+++ b/plc4go/spi/transports/udp/DelayedReply_test.go
@@ -0,0 +1,112 @@
+/*
+ * 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 udp
+
+import (
+       "context"
+       "net"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+// ackBytes is the exact 9-byte BACnet/IP SimpleAck observed in the field
+// (81 0a 00 09 ...). Bytes [2],[3] are the BVLC length = 0x0009.
+var ackBytes = []byte{0x81, 0x0a, 0x00, 0x09, 0x01, 0x00, 0x20, 0x06, 0x0f}
+
+// pollOneLikeCodec drives the transport exactly like 
bacnetip.MessageCodec.Receive:
+// GetNumBytesAvailableInBuffer (which arms a fresh short read deadline), then
+// PeekReadableBytes/Read with a *deadline-less* background context (the codec
+// polls with m.ctx, which is context.Background()-derived). It loops on the
+// same ~10ms cadence as the default codec ReceiveWork loop.
+func pollOneLikeCodec(t *testing.T, ti *TransportInstance, overall 
time.Duration) ([]byte, bool) {
+       t.Helper()
+       deadline := time.Now().Add(overall)
+       for time.Now().Before(deadline) {
+               num, err := ti.GetNumBytesAvailableInBuffer()
+               if err == nil && num >= 4 {
+                       hdr, err := ti.PeekReadableBytes(context.Background(), 
4)
+                       if err != nil {
+                               time.Sleep(10 * time.Millisecond)
+                               continue
+                       }
+                       size := uint32(uint16(hdr[2])<<8 | uint16(hdr[3]))
+                       if num < size {
+                               time.Sleep(10 * time.Millisecond)
+                               continue
+                       }
+                       data, err := ti.Read(context.Background(), size)
+                       if err != nil {
+                               time.Sleep(10 * time.Millisecond)
+                               continue
+                       }
+                       return data, true
+               }
+               time.Sleep(10 * time.Millisecond)
+       }
+       return nil, false
+}
+
+// TestTransportInstance_DelayedReplyAfterIdle reproduces the field scenario:
+// a request/reply works, the connection then sits idle well past the per-poll
+// read deadline, and a *delayed* reply arrives. It must still reach the codec.
+func TestTransportInstance_DelayedReplyAfterIdle(t *testing.T) {
+       for _, idle := range []time.Duration{0, 500 * time.Millisecond, 2 * 
time.Second} {
+               t.Run(idle.String(), func(t *testing.T) {
+                       peer, err := net.ListenUDP("udp4", &net.UDPAddr{IP: 
net.IPv4(127, 0, 0, 1), Port: 0})
+                       require.NoError(t, err)
+                       t.Cleanup(func() { _ = peer.Close() })
+
+                       ti := NewTransportInstance(nil, 
peer.LocalAddr().(*net.UDPAddr), false, nil)
+                       require.NoError(t, ti.Connect(t.Context()))
+                       t.Cleanup(func() { _ = ti.Close() })
+
+                       // First exchange: client writes so the peer learns the 
client addr.
+                       require.NoError(t, ti.Write(context.Background(), 
ackBytes))
+                       buf := make([]byte, 4096)
+                       require.NoError(t, 
peer.SetReadDeadline(time.Now().Add(2*time.Second)))
+                       _, clientAddr, err := peer.ReadFromUDP(buf)
+                       require.NoError(t, err)
+
+                       // Immediate reply must arrive.
+                       _, err = peer.WriteToUDP(ackBytes, clientAddr)
+                       require.NoError(t, err)
+                       _, ok := pollOneLikeCodec(t, ti, 2*time.Second)
+                       require.True(t, ok, "immediate reply lost")
+
+                       // Idle gap: the receive loop keeps polling (arming 
10ms deadlines that
+                       // keep expiring) with no traffic.
+                       if idle > 0 {
+                               stop := time.Now().Add(idle)
+                               for time.Now().Before(stop) {
+                                       _, _ = ti.GetNumBytesAvailableInBuffer()
+                                       time.Sleep(10 * time.Millisecond)
+                               }
+                       }
+
+                       // Delayed reply after the idle gap.
+                       _, err = peer.WriteToUDP(ackBytes, clientAddr)
+                       require.NoError(t, err)
+                       _, ok = pollOneLikeCodec(t, ti, 2*time.Second)
+                       require.True(t, ok, "delayed reply after %s idle gap 
was lost", idle)
+               })
+       }
+}

Reply via email to