]> git.giacomolli.uk - onehouse/blob - driver/m31/tcp_client_test.go
6e4979b80ac81d443505bda5f0ca41f5bb2c2e1a
[onehouse] / driver / m31 / tcp_client_test.go
1 package m31
2
3 import (
4 "context"
5 "encoding/binary"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "os"
11 "testing"
12 "testing/synctest"
13 "time"
14
15 "github.com/stretchr/testify/require"
16
17 "onehouse/driver"
18 )
19
20 func TestTCPClientEncodesAndChecksModbusOperations(t *testing.T) {
21 testCases := []struct {
22 name string
23 call func(*tcpClient) error
24 request []byte
25 reply []byte
26 }{
27 {
28 name: "reads low bit first across byte boundaries",
29 call: func(c *tcpClient) error {
30 value, err := c.ReadInputs(t.Context(), 9)
31 require.Equal(t, []bool{true, false, true, false, false, false, false, false, true}, value)
32
33 return err
34 },
35 request: []byte{2, 0, 0, 0, 9},
36 reply: []byte{2, 2, 5, 1},
37 },
38 {
39 name: "reads coil state",
40 call: func(c *tcpClient) error {
41 value, err := c.ReadCoils(t.Context(), 1)
42 require.Equal(t, []bool{true}, value)
43
44 return err
45 },
46 request: []byte{1, 0, 0, 0, 1},
47 reply: []byte{1, 1, 1},
48 },
49 {
50 name: "reads full sixteen bit registers",
51 call: func(c *tcpClient) error {
52 value, err := c.ReadRegisters(t.Context(), 8, 2)
53 require.Equal(t, []uint16{10000, 65535}, value)
54
55 return err
56 },
57 request: []byte{3, 0, 8, 0, 2},
58 reply: []byte{3, 4, 0x27, 0x10, 0xff, 0xff},
59 },
60 {
61 name: "writes true with function five",
62 call: func(c *tcpClient) error {
63 return c.WriteCoil(t.Context(), 7, true)
64 },
65 request: []byte{5, 0, 7, 0xff, 0},
66 reply: []byte{5, 0, 7, 0xff, 0},
67 },
68 {
69 name: "writes false with function five",
70 call: func(c *tcpClient) error {
71 return c.WriteCoil(t.Context(), 7, false)
72 },
73 request: []byte{5, 0, 7, 0, 0},
74 reply: []byte{5, 0, 7, 0, 0},
75 },
76 {
77 name: "writes a packed contiguous coil range",
78 call: func(c *tcpClient) error {
79 return c.WriteCoils(t.Context(), 6, []bool{true, false, true, false, false, false, false, false, true})
80 },
81 request: []byte{15, 0, 6, 0, 9, 2, 5, 1},
82 reply: []byte{15, 0, 6, 0, 9},
83 },
84 {
85 name: "writes a holding register with function sixteen",
86 call: func(c *tcpClient) error {
87 return c.WriteRegister(t.Context(), 0x30d4, 200)
88 },
89 request: []byte{16, 0x30, 0xd4, 0, 1, 2, 0, 200},
90 reply: []byte{16, 0x30, 0xd4, 0, 1},
91 },
92 }
93
94 for _, tc := range testCases {
95 t.Run(tc.name, func(t *testing.T) {
96 request, err := wireCall(t, tc.call, func(header []byte) []byte {
97 return append(append(header[:4:4], words(uint16(len(tc.reply)+1))...), append([]byte{7}, tc.reply...)...)
98 })
99 require.NoError(t, err)
100 require.Equal(t, append([]byte{0, 1, 0, 0, 0, byte(len(tc.request) + 1), 7}, tc.request...), request)
101 })
102 }
103 }
104
105 func TestTCPClientRejectsInvalidAndTruncatedResponses(t *testing.T) {
106 testCases := []struct {
107 name string
108 reply []byte
109 }{
110 {name: "rejects another transaction", reply: []byte{0, 2, 0, 0, 0, 4, 7, 1, 1, 0}},
111 {name: "rejects another protocol", reply: []byte{0, 1, 0, 1, 0, 4, 7, 1, 1, 0}},
112 {name: "rejects another unit", reply: []byte{0, 1, 0, 0, 0, 4, 8, 1, 1, 0}},
113 {name: "rejects empty PDU", reply: []byte{0, 1, 0, 0, 0, 1, 7}},
114 {name: "rejects excessive PDU", reply: []byte{0, 1, 0, 0, 0, 255, 7}},
115 {name: "rejects truncated header", reply: []byte{0, 1}},
116 {name: "rejects truncated body", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1}},
117 {name: "rejects wrong function", reply: []byte{0, 1, 0, 0, 0, 4, 7, 2, 1, 0}},
118 {name: "rejects exception", reply: []byte{0, 1, 0, 0, 0, 3, 7, 0x81, 2}},
119 {name: "rejects wrong byte count", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1, 2, 0}},
120 }
121
122 for _, tc := range testCases {
123 t.Run(tc.name, func(t *testing.T) {
124 _, err := wireCall(t, func(c *tcpClient) error {
125 _, err := c.ReadCoils(t.Context(), 1)
126 return err
127 }, func([]byte) []byte {
128 return tc.reply
129 })
130 require.Error(t, err)
131 })
132 }
133 }
134
135 func TestTCPClientRejectsInvalidRangesAndAcknowledgements(t *testing.T) {
136 c := &tcpClient{}
137 _, err := c.ReadInputs(t.Context(), 0)
138 require.Error(t, err)
139
140 _, err = c.ReadCoils(t.Context(), 2001)
141 require.Error(t, err)
142
143 _, err = c.ReadRegisters(t.Context(), 0, 0)
144 require.Error(t, err)
145
146 _, err = c.ReadRegisters(t.Context(), 0, 126)
147 require.Error(t, err)
148
149 _, err = c.ReadRegisters(t.Context(), 65535, 2)
150 require.Error(t, err)
151 require.Error(t, c.WriteCoils(t.Context(), 0, nil))
152 require.Error(t, c.WriteCoils(t.Context(), 65535, []bool{true, true}))
153 require.Error(t, c.WriteCoils(t.Context(), 0, make([]bool, 1969)))
154 require.Error(t, c.WriteCoil(t.Context(), 0, true))
155 require.NoError(t, c.Close())
156 require.Error(t, c.Connect(t.Context(), "", 0))
157 require.Error(t, c.Connect(t.Context(), "", 248))
158 require.Error(t, c.Connect(t.Context(), "", 1))
159
160 _, err = wireCall(t, func(c *tcpClient) error {
161 return c.WriteRegister(t.Context(), 0, 20)
162 }, func(header []byte) []byte {
163 return append(header[:7:7], 16, 0, 0, 0, 2)
164 })
165 require.Error(t, err)
166
167 _, err = wireCall(t, func(c *tcpClient) error {
168 _, readErr := c.ReadRegisters(t.Context(), 0, 1)
169 return readErr
170 }, func(header []byte) []byte {
171 return append(header[:7:7], 3, 3, 0, 0, 0)
172 })
173 require.Error(t, err)
174 }
175
176 func TestTCPClientCancellationAndTimeouts(t *testing.T) {
177 testCases := []struct {
178 name string
179 cancel bool
180 write bool
181 }{
182 {name: "cancels a blocked write", cancel: true, write: true},
183 {name: "cancels a blocked read", cancel: true},
184 {name: "limits a blocked write", write: true},
185 {name: "limits a blocked read"},
186 }
187
188 for _, tc := range testCases {
189 t.Run(tc.name, func(t *testing.T) {
190 synctest.Test(t, func(t *testing.T) {
191 conn, peer := net.Pipe()
192 c := &tcpClient{conn: conn, unit: 1}
193 t.Cleanup(func() {
194 require.NoError(t, c.Close())
195 require.NoError(t, peer.Close())
196 })
197
198 ctx, cancel := context.WithCancel(t.Context())
199 defer cancel()
200
201 result := make(chan error, 1)
202
203 if !tc.write {
204 go func() {
205 var request [12]byte
206
207 _, err := io.ReadFull(peer, request[:])
208 result <- err
209 }()
210 }
211
212 done := make(chan error, 1)
213 go func() {
214 _, err := c.ReadCoils(ctx, 1)
215 done <- err
216 }()
217
218 synctest.Wait()
219
220 if !tc.write {
221 require.NoError(t, <-result)
222 }
223
224 if tc.cancel {
225 cancel()
226 } else {
227 time.Sleep(2 * time.Second)
228 }
229
230 err := <-done
231 require.Error(t, err)
232
233 if tc.cancel {
234 require.ErrorIs(t, err, context.Canceled)
235 }
236 })
237 })
238 }
239 }
240
241 func TestTCPClientCancellationIsAnExpectedRunnerStop(t *testing.T) {
242 testCases := []struct {
243 name string
244 reading bool
245 }{
246 {name: "stops a blocked write"},
247 {name: "stops a blocked read", reading: true},
248 }
249
250 for _, tc := range testCases {
251 t.Run(tc.name, func(t *testing.T) {
252 synctest.Test(t, func(t *testing.T) {
253 conn, peer := net.Pipe()
254 c := &tcpClient{conn: conn, unit: 1}
255
256 t.Cleanup(func() {
257 require.NoError(t, peer.Close())
258 })
259
260 ctx, cancel := context.WithCancel(t.Context())
261 defer cancel()
262
263 ready := make(chan error, 1)
264 if tc.reading {
265 go func() {
266 var request [12]byte
267
268 _, err := io.ReadFull(peer, request[:])
269 ready <- err
270 }()
271 }
272
273 done := make(chan error, 1)
274 go func() {
275 done <- driver.Run(ctx, []driver.Driver{&requestDriver{client: c}}, nil)
276 }()
277
278 synctest.Wait()
279
280 if tc.reading {
281 require.NoError(t, <-ready)
282 }
283
284 cancel()
285 require.NoError(t, <-done)
286 })
287 })
288 }
289 }
290
291 func TestTCPClientUsesCallerDeadlineAndStartsClosed(t *testing.T) {
292 client := NewTCPClient()
293 _, err := client.ReadCoils(t.Context(), 1)
294 require.Error(t, err)
295 require.NoError(t, client.Close())
296 synctest.Test(t, func(t *testing.T) {
297 conn, peer := net.Pipe()
298 c := &tcpClient{conn: conn, unit: 1}
299 t.Cleanup(func() {
300 require.NoError(t, c.Close())
301 require.NoError(t, peer.Close())
302 })
303
304 ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
305 defer cancel()
306
307 started := time.Now()
308 _, err := c.ReadCoils(ctx, 1)
309 require.Error(t, err)
310 require.Equal(t, time.Millisecond, time.Since(started))
311 })
312 }
313
314 func TestTCPClientCancellationPreservesIndependentFailure(t *testing.T) {
315 synctest.Test(t, func(t *testing.T) {
316 conn, peer := net.Pipe()
317 t.Cleanup(func() {
318 require.NoError(t, peer.Close())
319 })
320
321 ctx, cancel := context.WithCancel(t.Context())
322 defer cancel()
323
324 failure := errors.New("independent write failure")
325 transport := &interruptConn{
326 Conn: conn,
327 cancel: cancel,
328 failure: errors.Join(os.ErrDeadlineExceeded, failure),
329 interrupted: make(chan struct{}),
330 }
331 c := &tcpClient{conn: transport, unit: 1}
332 t.Cleanup(func() {
333 require.NoError(t, c.Close())
334 })
335
336 _, err := c.ReadCoils(ctx, 1)
337 require.ErrorIs(t, err, failure)
338 require.ErrorIs(t, err, context.Canceled)
339 })
340 }
341
342 type requestDriver struct {
343 client *tcpClient
344 }
345
346 func (d *requestDriver) Run(ctx context.Context) (err error) {
347 defer func() {
348 err = errors.Join(err, d.client.Close())
349 }()
350
351 _, err = d.client.ReadCoils(ctx, 1)
352
353 return err
354 }
355
356 func (d *requestDriver) Refresh() {
357 }
358
359 type interruptConn struct {
360 net.Conn
361 cancel context.CancelFunc
362 failure error
363 interrupted chan struct{}
364 }
365
366 func (c *interruptConn) Write([]byte) (int, error) {
367 c.cancel()
368 <-c.interrupted
369
370 return 0, c.failure
371 }
372
373 func (c *interruptConn) SetDeadline(deadline time.Time) error {
374 defer close(c.interrupted)
375
376 err := c.Conn.SetDeadline(deadline)
377 if err != nil {
378 return fmt.Errorf("interrupt fake connection: %w", err)
379 }
380
381 return nil
382 }
383
384 func wireCall(t *testing.T, call func(*tcpClient) error, response func([]byte) []byte) ([]byte, error) {
385 t.Helper()
386
387 conn, peer := net.Pipe()
388 c := &tcpClient{conn: conn, unit: 7}
389 t.Cleanup(func() {
390 require.NoError(t, c.Close())
391 require.NoError(t, peer.Close())
392 })
393
394 requests := make(chan []byte, 1)
395 done := make(chan error, 1)
396
397 go func() {
398 var header [7]byte
399
400 _, err := io.ReadFull(peer, header[:])
401 if err != nil {
402 done <- err
403 return
404 }
405
406 body := make([]byte, int(binary.BigEndian.Uint16(header[4:6]))-1)
407 _, err = io.ReadFull(peer, body)
408 requests <- append(header[:7:7], body...)
409 if err == nil {
410 _, err = peer.Write(response(header[:]))
411 }
412
413 if errors.Is(err, io.ErrClosedPipe) {
414 err = nil
415 }
416
417 err = errors.Join(err, peer.Close())
418 done <- err
419 }()
420
421 err := call(c)
422 require.NoError(t, c.Close())
423 require.NoError(t, <-done)
424
425 return <-requests, err
426 }