Skip to content

hci: add unit tests, and correct what they found - #476

Open
deadprogram wants to merge 3 commits into
hci-subpackage-movefrom
hci-subpackage
Open

hci: add unit tests, and correct what they found#476
deadprogram wants to merge 3 commits into
hci-subpackage-movefrom
hci-subpackage

Conversation

@deadprogram

@deadprogram deadprogram commented Sep 9, 2026

Copy link
Copy Markdown
Member

Adds unit tests to the HCI protocol stack, and corrects the eight defects that
they found.

This is the last of three stacked pull requests. It builds on #478
hci: move the protocol stack into a subpackage, which took the build tags off
the protocol code and made these tests possible.

Tests

219 test cases, 87 percent statement coverage, race clean. The fake transport
comes in two flavours: a stream one that models a UART, including a packet
split over two reads, and a packet oriented one that models the CYW43439 ring,
including the read that does not fit. There is also a sweep over every ATT
opcode, every HCI event, every LE subevent and every L2CAP signalling code at
every length up to 24 bytes, which is what found most of the following.

Defects the tests found

All of these are in code that has shipped.

  1. Find Information Response panicked. Its format byte was treated as an
    entry length, so a well formed response from any peer sliced past the end of
    the buffer. Reachable over the air.
  2. Read By Type and Read By Group Type responses took their entry length
    off the wire with no bounds check. A bad length read past the end, and a
    zero length looped forever.
  3. No ATT opcode checked its length before reading, so a two byte protocol
    data unit panicked.
  4. ReadBdAddr returned the wrong address. cmdResponse started at the
    event length byte rather than at the return parameters, so Address()
    returned 0a:01:09:10:00:11. CommandResponse() now returns the return
    parameters alone.
  5. ReadLEBufferSize read the raw packet buffer, taking the packet type and
    event code as the packet length, and raised the maximum MTU to 3588 against
    response buffers sized for 248. Nothing calls it yet, so the overflow was
    latent.
  6. MTUReq asked for an MTU of zero, because it sent the connection MTU
    before any exchange.
  7. att.mtu was never assigned, so GetMTU always returned zero and the
    server used zero as the size budget for a discovery response, sending one
    attribute per round trip.
  8. L2CAP accepted a truncated connection parameter update request, answering
    it as accepted with zeroed parameters.

One protocol defect is not fixed here, because it is a behaviour change on
the peripheral discovery path that wants hardware validation: the Find
Information Response that this stack sends picks its format from the attribute
type rather than the UUID width, so a database whose first match is a service
declaration answers with format 2 and 16 bit UUIDs. It is marked with a TODO.

Three commits, in order

  1. hci: make the timeouts settable so that tests do not wait, so a timeout
    test does not wait 10 seconds.
  2. hci: add unit tests, and correct what they found.
  3. ci: test every package, and add a faster HCI smoke test. A new
    make smoketest-hci builds the four HCI transports and two bledebug
    configurations, which nothing covered before.

Size

-size=short, against #478:

target example flash before after RAM before after
nano-rp2040 discover 41632 42784 6516 6524
nano-rp2040 heartrate 61068 61964 8460 8468
arduino-nano33 discover 39812 40908 5696 5704
circuitplay-express hci hci_uart advertisement 56768 58036 5844 5852
pico-w discover 671072 671792 37016 37024
xiao-esp32c3 discover 307898 308614 58240 58256

The growth is the bounds checks. RAM is flat.

Verification

go vet, go test and go test -race over ./ ./ble ./hci, plus
make smoketest-tinygo, smoketest-linux, smoketest-windows and
smoketest-hci.

Run on hardware. examples/discover, which is the central path, works on one
ninafw board, one pico-w and one esp32, against dev, against the move and
against the tests. examples/heartrate, which is the peripheral path, was also
checked on a ninafw board with a host as the central: the service and
characteristic discovery, the MTU and the read permissions are the same in all
three. The peripheral path is not yet checked on pico-w or esp32.

@deadprogram

Copy link
Copy Markdown
Member Author

UPDATE: tested and working on xiao-esp32c3, pico-w, and nano-rp2040 boards for both central and periperal roles!

@acouvreur

Copy link
Copy Markdown
Member

Given the nature of the refactor you might want to stack refactors on multiple PRs.

The ble package makes sense and probably should have its own PR for clarity

@deadprogram

Copy link
Copy Markdown
Member Author

Given the nature of the refactor you might want to stack refactors on multiple PRs.

The ble package makes sense and probably should have its own PR for clarity

@acouvreur an excellent idea, I am working on that right now...

@acouvreur

Copy link
Copy Markdown
Member

Given the nature of the refactor you might want to stack refactors on multiple PRs.

The ble package makes sense and probably should have its own PR for clarity

@acouvreur an excellent idea, I am working on that right now...

Are youbsure you are willing to go forward with this whole refactor ? This is a huge set of breaking change.

I'm not against it, far from it. But I recall having some conversation with you about the public API of this repository.


If we are willing to go forward with these refactors, that would be a good opportunity to discuss some other refactors before releasing.

@deadprogram

Copy link
Copy Markdown
Member Author

I do not think that there are any breaking changes for external users.

@deadprogram deadprogram changed the title hci: move the protocol stack into a subpackage hci: add unit tests, and correct what they found Sep 9, 2026
@deadprogram
deadprogram changed the base branch from dev to hci-subpackage-move September 9, 2026 19:14
@deadprogram

Copy link
Copy Markdown
Member Author

Good call. The stack is now three pull requests, and each commit boundary
builds and passes on its own:

Review order is 477, 478, then 476. GitHub retargets each one to dev as the
one below it merges.

@acouvreur

Copy link
Copy Markdown
Member

@deadprogram I would recommend you to create a stacked pull request, so that rebasing is done automatically.

Right now the merge of #477 is now blocking #478 for conflicts. With the stacked PR this will be avoided.

image

A test for a timeout path had to wait 3 or 10 seconds of real time. The
command timeout, the response timeout and the delay between polls are now
fields, so a test shortens them and the path finishes at once.

The defaults are unchanged. The fields stay unexported, because the
defaults are the only supported setting.

The command timeout compared (now-start)/int64(time.Second) > 3, which
fires somewhere between 3 and 4 seconds. It is now time.Since(start)
against the field.
The protocol code had no tests, because the build tags kept it off the
host. It now has 190 test cases and 87 percent statement coverage, with a
fake transport in two flavours: a stream one that models a UART, including
a packet split over two reads, and a packet oriented one that models the
CYW43439 ring, including the read that does not fit.

The tests found six defects, all in code that has shipped:

Find Information Response treated its format byte as an entry length, so a
well formed response from any peer sliced past the end of the buffer and
panicked. Read By Type Response and Read By Group Type Response took their
entry length off the wire without a bounds check, so a bad length read past
the end, and a zero length looped forever.

No ATT opcode checked that the protocol data unit was long enough before
reading it, so a two byte one panicked. handleData now checks the minimum
length for the opcode first, and each unmarshaller checks its own input. A
sweep over every opcode at every length up to 24 bytes covers this, and the
same sweep runs over every HCI event, every LE subevent and every L2CAP
signalling code.

ReadBdAddr copied the wrong bytes, because cmdResponse started at the
event length byte rather than at the return parameters. Address returned
0a:01:09:10:00:11 instead of the controller address. CommandResponse now
returns the return parameters alone, which is what a caller wants.

ReadLEBufferSize read the raw read buffer rather than the response, so it
took the packet type and event code as the packet length and raised the
maximum MTU to 3588. Nothing calls it yet, so the overflow that would
follow was still latent. It now reads the response and clamps to
MaximumMTU, which is what the response buffers are sized for.

MTUReq asked for the connection MTU, which is zero before an exchange. It
now asks for the largest MTU this stack accepts.

att.mtu was never assigned, so MTU and GetMTU always returned zero, and
the server used zero as the size budget for a discovery response and so
sent one attribute per round trip. It now starts at the ATT default of 23
and follows the exchange.

L2CAP discarded the error from unmarshalling a connection parameter update
request, so a truncated one was answered as accepted with zeroed
parameters, and handleData read the first two bytes without checking that
they were there.

The Find Information Response that this stack sends picks its format from
the attribute type rather than the UUID width, so a database whose first
match is a service declaration answers with format 2 and 16 bit UUIDs.
That is a protocol correction rather than a crash, so it is marked with a
TODO and left for its own change.

Also removes three serialisers that nothing called.
go test with no arguments only tested the root package, so the new ble and
hci packages were never tested. It now names the three packages. It cannot
be ./... , because the baremetal examples import machine and do not build
on the host.

The Linux job also runs the race detector, because ATT uses a mutex and a
channel across goroutines.

make smoketest-hci builds the four HCI transports for a faster loop than
the whole matrix, and adds two bledebug builds, which nothing covered.
@deadprogram
deadprogram added this pull request to stack #479 September 9, 2026 19:35
@deadprogram

Copy link
Copy Markdown
Member Author

@acouvreur I created a stack for the remaining two, sorry I did not do that initially.

@deadprogram

Copy link
Copy Markdown
Member Author

Ready for stacked review @acouvreur

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants