Skip to content

Vllp client - #64

Merged
andoma merged 10 commits into
masterfrom
vllp-client
Sep 8, 2026
Merged

andoma merged 10 commits into
masterfrom
vllp-client

Conversation

@andoma

@andoma andoma commented Sep 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

andoma and others added 10 commits September 8, 2026 14:32
All of these are reachable by a peer on the bus, and none of them need
malice -- a noisy CAN bus or a peer that has lost sync is enough. They
were found by a new adversarial-peer test suite and by tightening the
buffer accounting in the existing one.

net/vllp.c:

 - vllp_channel_receive() leaked the whole reassembled chain when a
   message failed its CRC. STAILQ_INIT() has already detached it from
   the channel at that point, so pb is the only remaining reference and
   the channel teardown that follows cannot help. One corrupted message
   costs a message worth of buffers, permanently, so a device on a noisy
   bus bleeds its pbuf pool until it stops talking to anything.

 - The reassembly limit was enforced by closing the channel, which calls
   into the application bound to it -- but the management channel is the
   one channel with no application. A peer sending more fragments than
   the limit without ever setting the last-fragment bit dereferenced a
   NULL function pointer on the net thread. It now drops the link, which
   is the only sensible response to an oversized management message, and
   an assert documents the invariant in vllp_channel_net_close().

 - vllp_disconnect() reset the management channel's transmit queue but
   not its reassembly queue. The management channel outlives the session,
   so a half-reassembled inbound message stayed queued and the next
   session's fragments were appended to it: the message then failed its
   CRC, which reset the link, which stranded another partial message. A
   link that resets under load never recovered.

 - The management-channel handler's default case returned without freeing
   the message it had been handed, leaking a buffer per frame carrying an
   opcode the spec reserves.

net/pbuf.c:

 - pbuf_free_queue_irq_blocked() freed the chain but left the queue head
   pointing at the freed buffers. Most callers discard the containing
   object immediately and never notice; the ones that flush a queue and
   keep using it (vllp_disconnect() on the management channel, l2cap's
   reassembly flush) freed the same buffers again on the next flush and
   corrupted the pool free list, which then surfaces as a crash in
   whatever unrelated code allocates next. It now leaves the queue empty.

Tests:

 - suite_vllp_frames.c: a new suite driving the server from a peer that
   hand-assembles frames, so the paths a correct client never asks for
   get exercised on purpose -- oversized management messages, reserved
   opcodes, and a partial message left behind by a session reset. Each
   phase then checks that the server still serves and that the pool is
   where it started.

 - suite_vllp.c: a heavy payload-corruption phase whose only assertion is
   buffer accounting, plus an end-of-run pool check. The existing
   corrupt-1 phase only ever loses a single one-fragment message, which
   is small enough to hide inside any sane tolerance.

Every fix was verified by reverting it and confirming the suite fails and
names the cause.

Co-Authored-By: Claude <noreply@anthropic.com>
Lets a gateway on a CAN bus offer a console for each unit on the bus: the
gateway is a VLLP client on one link per unit, each link keeps a channel
open to the unit's ordinary "shell" service, and that channel is wired to
a local virtual console. An operator telnets to the gateway and runs
"attach unit3". Nothing changes on the units.

The client role lives in src/net/vllp.c alongside the server rather than
in a file of its own -- it is the same protocol, and the fragmentation,
flow control, retransmission and reassembly code is shared verbatim. What
the client adds is the handshake (a cookie, and a SYN once a second until
the server answers), channel-id allocation, the OPEN/OPEN_RESPONSE half of
channel management, and the mirrored per-channel CRC IVs.

Behind ENABLE_VLLP_CLIENT, default no. This has to be a compile-time
option rather than dead code the linker drops: the client paths hang off
vllp_rx(), the keepalive timer and vllp_maybe_tx(), all of which a server
needs, so --gc-sections cannot reach them. Measured on vexpress-a9, a
server-only build pays 336 bytes of text and nothing at all in RAM --
sizeof(vllp_t) is unchanged, and cmd_show_vllp comes out the same size it
was. With the client compiled in it is 3.5 KB and 48 bytes per link
endpoint.

The show_vllp additions are behind the flag as well, including the
per-channel buffer counts. Those are useful on a server too -- they are
what localises a draining pool to a channel and a queue -- but CLI
commands live in a KEEP'd section, so anything added there is paid for by
every build whether it is reachable or not.

The bind timer is set up in vllp_client_create() rather than on the shared
path, because assigning its callback is what makes the whole bind
machinery reachable: doing it in vllp_create() linked ~600 bytes of it
into images that only ever instantiate servers.

mios ships two orthogonal primitives here and leaves the joining to the
application, because which CAN ids, which console names, which remote
service and how many units are all board policy:

    vcon_t *vc = vcon_create("unit1", 4096, 128);
    vllp_t *v  = vllp_client_create(0x530, 0x531, 8, 3);
    vllp_client_bind(v, "shell", unit_console_open, vc);

vllp_client_bind() keeps a channel open for the life of the system,
re-opening it on every new session and, after a refused open, on a
backoff. It asks the application to bind a fresh pushpull each time, so
the pushpull contract ("after close() the network side calls nothing
again") holds across reconnects without the application having to know
about them.

vcon_pushpull_open() is the vcon end of that. It spawns no thread for any
number of consoles: push() and pull() run in net context, and keystrokes
reach the engine through vcon_set_backend_notify(), which is new. Without
that hook pull() only runs when the once-a-second keepalive next moves the
engine, so a keystroke waits up to a full second -- measured 1 ms with the
hook against 1 s without. The alternative was a poll thread per console,
which on a nine-unit gateway is nine stacks.

ENABLE_VCON was off everywhere and no in-tree platform set it, so none of
src/util/vcon.c or src/shell/cmd_vcon.c had ever been compiled, let alone
executed, by CI. host.mk now enables it and there are suites for it.

Tests, all in virtual time:

 - vcon: the console itself. Scrollback replay, cursor resync when a
   client falls behind the window, multi-client mirroring, input merging,
   the ring's wraparound arithmetic, and the consoles/attach commands
   including the ^A escape handling.

 - vllp-client: the client against the server, over a looped-back virtual
   CAN bus. Includes the two invariants that fail silently and totally if
   they are wrong -- the mirrored CRC IVs, and that the client advances
   its per-channel IV counter for every OPEN it sends because the server
   advances its own for every OPEN it receives, including refused ones.

 - vcon-vllp: nine units end to end, with real remote shells. Crosstalk
   between links, both directions busy at once, detach/re-attach,
   keystroke latency, unit reboots, and loss/duplication/corruption. Also
   at a CAN-sized buffer pool and at a starved one with injected
   allocation failures.

Supporting scaffolding: vcan_loop.{c,h} loops a vcan back on itself so
many endpoints in one binary talk over the virtual bus, and carries the
fault injection; testterm.{c,h} is a buffered stream standing in for an
attached terminal.

Also completes PBUF_ORIGIN_TRACE, which plumbed an origin string through
the pool but never stored it. It now records the origin per arena slot and
pbuf_origin_dump() lists what is checked out, which is what identified the
CRC-failure leak fixed in the previous commit. Left switched off.

Co-Authored-By: Claude <noreply@anthropic.com>
Running "attach" on a shell reached over VLLP brought the device down, and
once it stopped doing that, showed nothing.

The crash: cmd_attach() polls its terminal through vcon_client_wait(),
that terminal is the pushpull-to-stream bridge, and pushpull streams had
no poll method -- so poll() hit its assert. Nothing had ever polled one
before; the bridge is normally read and written straight from a shell
thread. The same applies to any pushpull transport, so BLE L2CAP and MBUS
shells were equally affected, and splice_bidir() on such a stream would
have gone the same way.

pushpull_stream_poll() reports readable when a message is queued and also
when the stream has shut down, so a poller wakes up to see the shutdown
rather than sleeping through it, and it gives the network side the same
"I have room" nudge that read() does before sleeping -- without which a
peer that was told to stop sending is never told it may resume.

The silence: pushpull_stream_write() only hands data to the network once
a fragment fills or someone flushes, and neither cmd_attach() nor
vcon_bind()'s pump ever flushed their terminal. Against a plain byte
stream that goes unnoticed; against a pushpull stream a nested session's
console output sat in the buffer indefinitely. Keystrokes arrived and the
far shell answered -- its scrollback grew -- but nothing came back to the
operator, so the console looked dead. Both pumps now flush when they run
out of things to do, which still lets a burst coalesce into full
fragments.

cmd_attach() also ignored a negative read from its terminal. With the poll
method in place a shut-down terminal is always ready, so the loop would
have made no progress, slept on a stream that never blocks, and spun at
priority 2 forever. It now leaves the loop, which is also what should
happen when the link carrying an attached session drops.

Found by asking what happens with nested sessions -- attach to a unit,
then attach to something else from that unit's shell. That is a reasonable
thing to do on a bus of units and none of it worked.

The escape handling itself needed no change and behaves the way screen
does: the outermost cmd_attach() is the first filter a byte meets, so ^A d
always detaches the outermost session from any depth, and reaching an
inner level means doubling the prefix for every level above it. Left that
way deliberately -- preferring the innermost cannot be done without the
outer loop knowing whether an inner one exists, and the only channel it
could learn that on is the console output itself. Written down in
cmd_vcon.c, including that the count is a power of two and that detaching
an outer session leaves the inner ones attached.

The vcon-vllp suite grows a nested-attach phase that attaches to a unit,
attaches again from that unit's shell, checks that traffic actually flows
through both levels, and checks that each detach sequence unwinds exactly
one level.

Co-Authored-By: Claude <noreply@anthropic.com>
The other client suite runs mios against mios. That catches a great deal,
because only one of the two roles is new and the server is a reference
that was not touched -- but it cannot catch a convention both ends read
the same wrong way out of the same source file, and the VLLP wire has a
fair number of those: what the MTU byte in the SYN holds, the cookie
becoming the link CRC IV, the per-channel IV derivation and which end
inverts it, the counter deriving those IVs stepping in lockstep, the
OPEN/OPEN_RESPONSE encoding.

This suite drives the mios client against the host reference stack
(host/dsig/vllp.c, what the `dsig` tool ships) playing the server, in sim
mode, over the virtual CAN bus. The roles are the mirror of suite_vllp.c,
where the host client drives the mios server.

Two symmetric mutations show what it buys, both self-consistent so
mios-vs-mios stays green:

  - putting the unadapted MTU in the SYN and expecting it there:
    vllp-client PASS, vllp-xcheck FAIL
  - seeding the per-channel IV counter at a different value:
    vllp-client PASS, vllp-xcheck FAIL

hvllp_create_server() was already renamed by vllp_sim.h but never
declared, so this adds it to vllp_sim_api.h along with the result struct
the open-channel callback returns. Two notes there worth keeping: sim mode
has no rx-dispatch thread, so an accepted channel must return NULL
callbacks and be drained with hvllp_channel_read(); and a read that times
out marks the channel closed for good, so the peer blocks with a generous
deadline rather than polling on short ones.

The production pthread build of host/dsig is unaffected and still builds.

Co-Authored-By: Claude <noreply@anthropic.com>
stm32_bxcan_send_pb() copies into the transmit mailbox and leaves the
pbuf alone. The direct path in stm32_bxcan_output() gets away with that
because it returns the pbuf and the stack frees it, but the queued path
in the transmit interrupt spliced a pbuf off our own queue and then
dropped the pointer. Every frame that went through the software queue
leaked one pbuf, permanently.

Frames only queue when the mailbox is still busy, so this needed a
burst to show up, and dsig_send_cb() drains its whole queue back to
back. On a board with ten VLLP links the pool -- 32 buffers -- was gone
in seconds, which starves the whole stack, not just CAN. It went
unnoticed this long because a board has to emit dsig to reach the
queued path at all: the low-latency path drops rather than queues, and
until now the boards using bxcan only ever received.

Also guard the pbuf_drop() result, which was passed to send_pb()
unchecked, and release the mailbox if it does come back NULL so a
failure there cannot wedge transmission.

Co-Authored-By: Claude <noreply@anthropic.com>
The queue behind the transmit mailbox had no depth limit. A bus with no
other node on it never acknowledges a frame, so the mailbox stays busy
indefinitely and the queue grows until the pbuf pool is empty -- which
takes down every other protocol on the board, not just CAN.

Cap it and count the drops. FDCAN already behaves this way when its
hardware FIFO fills. Dropping is the right answer here: protocols that
care about a frame retransmit, and the ones that do not are periodic,
where the next sample supersedes whatever was dropped.

Co-Authored-By: Claude <noreply@anthropic.com>
print_info reported only the error counters, so there was no way to
tell from the shell whether the interface was moving packets at all,
whether it was dropping them, or whether receives were failing for want
of a pbuf -- the last of which was entirely silent. Diagnosing a
draining pbuf pool on a bxcan board meant guessing.

Report the same shape FDCAN already does: per-FIFO receive counts,
NoPbufs, transmitted, drops and the current queue depth. Decode the
last error code instead of printing the raw field, and put the three
bus-state flags on one line.

Co-Authored-By: Claude <noreply@anthropic.com>
PBUF_ORIGIN_TRACE records which allocation site owns each data buffer
and pbuf_origin_dump() lists them, but nothing ever called it, so
turning the option on produced no output and the tracking was
unreachable in practice.

Call it from pbuf_status(), so a build with the option on answers "who
is holding the pool" through the same command that already reports how
little of it is left. Still off by default.

Co-Authored-By: Claude <noreply@anthropic.com>
show_vllp listed the channel management channel as ESTABLISHED under a
Disconnected link, which reads as though something is up when nothing
is. Channel 14 is ESTABLISHED from the moment the link is created: it
is a sentinel for "needs no setup", not a state it ever reached.

The flow bits and the free-channel-id mask are just as misleading in
that state -- both print their reset values, and 0x3fff in particular
looks like information. So skip the whole per-channel block when there
is no session and print only the header and the binds, where "waiting"
is the fact that actually matters.

Co-Authored-By: Claude <noreply@anthropic.com>
An output filter was an allow-list only: first prefix match wins and no
match means the signal does not go out. That is the wrong shape when
all a board wants is to keep one id range off an interface, because
expressing it means enumerating every id the board legitimately emits
-- an inventory that silently drops the next signal someone adds.

An entry with DSIG_FLAG_DENY, followed by a catch-all, states the
constraint instead. Backward compatible: no match still means deny, so
existing allow-lists and a NULL filter behave exactly as before, and
dsig_output() consumes the bit so no driver sees it. Bit 0 is already
DSIG_FLAG_EXTENDED, which the bxcan output path reads.

mask_from_prefixlen(0) computed 1 << 32, which is undefined, so the
catch-all had no well-defined spelling until now. Nothing used a zero
prefix length before.

Co-Authored-By: Claude <noreply@anthropic.com>
@andoma
andoma merged commit 65d2658 into master Sep 8, 2026
1 check passed
@andoma
andoma deleted the vllp-client branch September 8, 2026 13:05
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.

1 participant