fix(dispenser): scan ACK responses without assuming 3-byte reads

This commit is contained in:
yurii 2026-09-30 00:37:58 +01:00
parent aef1bbbe57
commit bd15b054dd
4 changed files with 204 additions and 7 deletions

View File

@ -33,7 +33,7 @@ import (
)
const (
buildVersion = "v2.1.1"
buildVersion = "v2.1.2"
serviceName = "hardlink"
pollingFrequency = 8 * time.Second
)

View File

@ -295,11 +295,60 @@ func sendAndReadACK(ctx context.Context, port serialTransport, packet []byte, pr
if err := waitForSequence(ctx, processingDelay); err != nil {
return err
}
response := make([]byte, 3)
if err := readExact(ctx, port, response); err != nil {
return fmt.Errorf("read ACK: %w", err)
return readACK(ctx, port)
}
// readACK scans sequentially without reading beyond a complete candidate.
// Wrong-address candidates are consumed in full; no bytes persist across calls.
func readACK(ctx context.Context, port serialTransport) (err error) {
const scanLimit = 64
seen := make([]byte, 0, scanLimit)
defer func() {
if err != nil {
log.Warnf("dispenser ACK acquisition failed: address=% X seen=% X error=%v", Address, seen, err)
}
}()
var candidate [3]byte
used := 0
for len(seen) < scanLimit {
if err := ctx.Err(); err != nil {
return err
}
need := 1
if used > 0 {
need = len(candidate) - used
}
need = min(need, scanLimit-len(seen))
chunk := candidate[used : used+need]
n, readErr := port.Read(chunk)
seen = append(seen, chunk[:n]...)
log.Debugf("dispenser ACK RX: address=% X n=%d bytes=% X error=%v", Address, n, chunk[:n], readErr)
if err := ctx.Err(); err != nil {
return err
}
used += n
if used == 1 && candidate[0] != ACK && candidate[0] != NAK {
log.Debugf("dispenser ACK ignored leading byte: % X", candidate[:1])
used = 0
} else if used == len(candidate) {
if len(Address) >= 2 && candidate[1] == Address[0] && candidate[2] == Address[1] {
log.Debugf("dispenser ACK/NAK accepted: address=% X token=% X", Address, candidate[:])
if candidate[0] == ACK && len(seen) > len(candidate) {
log.Warnf("dispenser ACK resynchronized: address=% X ignored=% X accepted=% X", Address, seen[:len(seen)-len(candidate)], candidate[:])
}
return checkACK(candidate[:])
}
log.Debugf("dispenser ACK ignored wrong-address candidate: address=% X candidate=% X", Address, candidate[:])
used = 0
}
if readErr != nil {
return fmt.Errorf("read ACK: %w", readErr)
}
if n == 0 {
return fmt.Errorf("read ACK: %w", io.ErrNoProgress)
}
}
return checkACK(response)
return fmt.Errorf("read ACK: scan limit of %d bytes exhausted", scanLimit)
}
// queryStatus accepts only the fixed RF/AP payload sizes, before reading a body.

View File

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"os"
"testing"
"time"
)
@ -24,6 +25,146 @@ var vendorFrames = []struct {
{"RS", commandRS, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x53, 3, 2}},
}
func TestACKScannerFragmentation(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
wrong := []byte("15")
if address == "15" {
wrong = []byte("00")
}
for _, prefix := range [][]byte{nil, {0x30}, {0x10}, {address[1]}, {ACK, wrong[0], wrong[1], 0x30}, {NAK, wrong[0], wrong[1]}} {
for _, token := range []byte{ACK, NAK} {
wire := append(append([]byte(nil), prefix...), token, Address[0], Address[1])
for mask := 0; mask < 1<<(len(wire)-1); mask++ {
t.Run(fmt.Sprintf("%s/%X/%d", address, wire, mask), func(t *testing.T) {
p := &scriptedTransport{}
start := 0
for i := 1; i < len(wire); i++ {
if mask&(1<<(i-1)) != 0 {
p.chunks = append(p.chunks, wire[start:i])
start = i
}
}
p.chunks = append(p.chunks, wire[start:])
err := dispatchCommand(context.Background(), p, commandFC7, 0)
if (err != nil) != (token == NAK) {
t.Fatalf("dispatchCommand(% X)=%v", wire, err)
}
want := [][]byte{createPacket(Address, commandFC7)}
if token == ACK {
want = append(want, append([]byte{ENQ}, Address...))
}
if len(p.writes) != len(want) {
t.Fatalf("writes=% X want=% X", p.writes, want)
}
for i := range want {
if !bytes.Equal(p.writes[i], want[i]) {
t.Errorf("write=% X want=% X", p.writes[i], want[i])
}
}
if len(p.chunks) != 0 {
t.Errorf("unread token bytes=% X", p.chunks)
}
})
}
}
}
}
}
func TestACKScannerBoundsAndFollowingTransaction(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
ack := append([]byte{ACK}, Address...)
for _, leading := range []int{61, 62, 64} {
wire := append(bytes.Repeat([]byte{0xff}, leading), ack...)
p := &scriptedTransport{chunks: [][]byte{wire}}
err := dispatchCommand(context.Background(), p, commandRS, 0)
if (err == nil) != (leading == 61) {
t.Errorf("leading=%d error=%v", leading, err)
}
if leading != 61 && (len(p.writes) != 1 || len(bytes.Join(p.chunks, nil)) != len(wire)-64) {
t.Fatalf("scan exceeded bound: writes=% X remaining=% X", p.writes, p.chunks)
}
}
wire := append(append(append([]byte{0x10}, ack...), ack...), 0xfe)
p := &scriptedTransport{chunks: [][]byte{wire}}
for i := 0; i < 2; i++ {
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err != nil {
t.Fatal(err)
}
}
if len(p.writes) != 4 || !bytes.Equal(bytes.Join(p.chunks, nil), []byte{0xfe}) {
t.Fatalf("transaction boundary lost: writes=% X remaining=% X", p.writes, p.chunks)
}
}
}
type ackReadErrorTransport struct {
*scriptedTransport
reads, failAt int
err error
}
func (p *ackReadErrorTransport) Read(b []byte) (int, error) {
p.reads++
n, err := p.scriptedTransport.Read(b)
if p.reads == p.failAt {
return n, p.err
}
return n, err
}
func TestACKScannerReadErrors(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
for _, token := range []byte{ACK, NAK} {
for _, readErr := range []error{io.EOF, os.ErrDeadlineExceeded, io.ErrClosedPipe} {
for _, failAt := range []int{1, 2} {
p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{token, Address[0], Address[1]}}}, failAt: failAt, err: readErr}
err := dispatchCommand(context.Background(), p, commandFC7, 0)
if failAt == 1 && !errors.Is(err, readErr) {
t.Fatalf("incomplete token error=%v want=%v", err, readErr)
}
if failAt == 2 && ((err == nil) != (token == ACK) || errors.Is(err, readErr)) {
t.Fatalf("complete token %02X with read error returned %v", token, err)
}
wantWrites := 1
if failAt == 2 && token == ACK {
wantWrites = 2
}
if len(p.writes) != wantWrites || p.reads != failAt {
t.Fatalf("writes=%d reads=%d want=%d/%d", len(p.writes), p.reads, wantWrites, failAt)
}
}
}
}
for _, wire := range [][]byte{nil, {0x30, ACK, Address[0]}, {0xff, 0xfe}, {ACK, Address[0]}} {
p := &scriptedTransport{chunks: [][]byte{wire}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil || len(p.writes) != 1 {
t.Fatalf("incomplete/garbage % X: err=%v writes=% X", wire, err, p.writes)
}
}
for _, cancelAt := range []int{1, 2} {
ctx, cancel := context.WithCancel(context.Background())
p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{ACK, Address[0], Address[1]}}}, failAt: 2, err: io.EOF}
p.afterRead = func() {
if p.reads == cancelAt {
cancel()
}
}
err := dispatchCommand(ctx, p, commandFC7, 0)
cancel()
if !errors.Is(err, context.Canceled) || len(p.writes) != 1 || p.reads != cancelAt {
t.Fatalf("cancel read %d: err=%v writes=%d reads=%d", cancelAt, err, len(p.writes), p.reads)
}
}
}
}
func TestVendorOutboundFrames(t *testing.T) {
for _, tc := range vendorFrames {
t.Run(tc.name, func(t *testing.T) {
@ -216,7 +357,11 @@ func TestTransportCancellation(t *testing.T) {
case "processing wait":
p.afterWrite = cancel
case "after ACK":
p.afterRead = cancel
p.afterRead = func() {
if len(p.chunks) == 1 {
cancel()
}
}
case "after ENQ":
wantWrites = 2
p.afterWrite = func() {
@ -229,7 +374,7 @@ func TestTransportCancellation(t *testing.T) {
reads := 0
p.afterRead = func() {
reads++
if reads == 2 {
if reads == 3 {
cancel()
}
}

View File

@ -2,6 +2,9 @@
builtVersion is a const in main.go
#### v2.1.2 - 30 September 2026
fix(dispenser): scan ACK responses without assuming 3-byte reads
#### v2.1.1 - 28 September 2026
feat(dispenser): add worker-owned idle card prestaging