The NetAgent transaction is not serialised, and npuep already fixed it¶
#224 says octep's NetAgent request
block is staged and read outside the lock, so two callers can interleave. That is right, and the
scope is larger than the staging block: the window transaction itself is unserialised, because
the mutex is dropped in the middle of the wait.
This page is the design of the fix, written before any of it was built. Citations name the function, then the literal expression, then the file and line.
Status¶
The four changes of section D are built, in two pull requests:
| change | where | state |
|---|---|---|
| 1. the wait sleeps on a channel, with the all-ones and detach exits | #235 | merged, on the hardware since |
2. nwa_busy gates the transaction |
#235 | merged, on the hardware since |
| 3. the request block becomes arguments | #283 | built on the appliance, not yet installed |
4. nwa_last_* stays as the operator's record and nothing else reads it |
#283 | built on the appliance, not yet installed |
After #283 the one function is octep_nwa_request()
(contrib/octep/octep_nwa.c:616): op, sub, port and the two payload words are arguments, and the
reply is copied into the caller's struct octep_nwa_reply (contrib/octep/octep.h:2687) before the
mutex is dropped. The five port functions call it with a local reply. The nwa.request sysctl is
served by octep_nwa_do_request() (contrib/octep/octep_nwa.c:791), which reads the staged nwa.*
fields under the lock and passes them on, so the staging block exists for the operator alone. The
table in section B is kept as it stood when this was written, with the citations moved to where the
functions are now.
Tested on the appliance, 2026-10-07, the case section B says had no test: three hundred hand
requests through nwa.request in four seconds (GET of the link state for port tag 1) with the
link poll running its own transactions underneath. nwa.commands 849 to 1153, nwa.timeouts 0
before and after, nwa.releases unchanged, every port's link state the same before and after,
nwa.last showing the hand request's op, sub and port with a one-word reply. Four of the three
hundred reads of nwa.last showed the poll's transaction instead of the hand one: that is the
operator's record being the record of the LAST transaction, which is what it is now documented
to be, and the poll and the hand tool interleaving on the window exactly as the nwa_busy gate
allows them to. The requests themselves cannot mix any more, because there is no longer a place
for them to mix in.
Sections A to D below are the design as written, before #235 and #283.
The short answer¶
npuep has had the fix since it first carried a bridge, and it is two things and one field:
- the arguments are arguments, not fields in the softc, so there is nothing to stage and nothing to race over;
- the lock belongs to the caller, asserted rather than taken;
- and one field,
busy, withmsleep/wakeup, which serialises the window across the mutex being dropped in the wait.
The issue proposes "a request mutex, or an sx around stage-issue-read". That closes the staging
race and leaves the window race open. The shape below closes both and adds no second lock.
A. What is actually shared, and it is three things not one¶
The request block¶
uint32_t nwa_req_op; contrib/octep/octep.h:1668
uint32_t nwa_req_sub;
uint32_t nwa_req_port;
uint32_t nwa_req_param;
uint32_t nwa_req_param2; contrib/octep/octep.h:1672
Filled by each caller before the call, with no lock. octep_nwa_do_request() reads four of them
under the mutex at op = sc->nwa_req_op; (contrib/octep/octep_nwa.c:412) and the fifth later, at
rq[OCTEP_NWA_RQ_PAYLOAD / 4 + 1] = sc->nwa_req_param2; (contrib/octep/octep_nwa.c:522) - still
under the mutex, but the writes that put them there were not.
The result block¶
uint32_t nwa_last_op; contrib/octep/octep.h:1657
...
uint32_t nwa_last_status; contrib/octep/octep.h:1664
Written under the mutex at sc->nwa_last_op = op; (contrib/octep/octep_nwa.c:524) and by
octep_nwa_xfer() (:528), and then read by every caller after the mutex has been dropped at
mtx_unlock(&sc->mtx); (contrib/octep/octep_nwa.c:532).
The bring-up script already found what that costs in practice, for a different reason:
"nwa.last is the driver's one last-reply buffer, so a request the firmware answered with an error
leaves in it whatever was there before - and two payload words being present says only that some
transaction once put them there" (src/opnsense/scripts/octep/bringup.sh:295). One buffer, and
nothing ties a reading of it to the request that produced it.
And the window itself, which the issue does not name¶
octep_nwa_wait() (contrib/octep/octep_nwa.c:187) drops and retakes the mutex around each tick:
mtx_unlock(&sc->mtx); /* contrib/octep/octep_nwa.c:194 */
pause("octepnwa", hz / 100);
mtx_lock(&sc->mtx);
So sc->mtx is not held for the duration of a transaction. A second caller can take it during
any of those gaps, walk through octep_nwa_do_request()'s validation, call octep_nwa_release()
and write a fresh request into the window while the first transaction is still waiting for its
reply. There is no guard: nwa_busy does not exist anywhere in octep.
That is a different and worse race than the staging one. The staging race corrupts a request. This one corrupts the window, which both parties then read.
B. The five callers, and why it has not been seen¶
All six are in contrib/octep/octep_nwa.c; the second column is the line inside each one that
issues the transaction - before #283 by staging the block and calling octep_nwa_do_request, now
by calling octep_nwa_request with arguments (the sysctl still goes through the staging wrapper).
| caller, at its definition | issues at line | context |
|---|---|---|
octep_nwa_port_mac - contrib/octep/octep_nwa.c:829 |
665 | the bring-up path |
octep_nwa_port_speed - contrib/octep/octep_nwa.c:874 |
710 | the link poll |
octep_nwa_port_filter - contrib/octep/octep_nwa.c:904 |
740 | the link poll |
octep_nwa_port_promisc - contrib/octep/octep_nwa.c:930 |
766 | the link poll |
octep_nwa_port_link - contrib/octep/octep_nwa.c:945 |
781 | the link poll, taskqueue_thread |
octep_sysctl_nwa_request - contrib/octep/octep_nwa.c:806 |
645 | a user process, via nwa.request |
The poll's four cannot overlap each other: one task, one thread, issued in sequence. So reaching
this needs a second thread, which in practice means writing dev.octep.0.nwa.request while the poll
runs, or the bring-up script running against a live poll - and bringup.sh is documented as
something to run twice, "by hand afterwards when something is being changed".
That is why it has not been seen, not why it is safe, and the issue says so. Worth adding: the bring-up path is a real second thread, not only the diagnostic sysctl, and it issues NetAgent requests in a loop for twelve ports while the link poll is running.
C. The fix, from npuep, which solved all of it¶
One field, and the wait that makes it work¶
int busy; /* a transaction is in the window */
- contrib/npuep/npunwa.c:125
Used as a gate around the whole transaction:
static int
npunwa_command_n(struct npunwa_softc *sc, uint32_t op, uint32_t sub, uint32_t port,
const uint32_t *pl, int npl, uint32_t *reply, int nreply)
{ /* contrib/npuep/npunwa.c:360 */
mtx_assert(&sc->mtx, MA_OWNED); /* :365 */
while (sc->busy) { /* :367 */
if (!sc->running)
return (ENXIO);
msleep(&sc->busy, &sc->mtx, 0, "npunwaq", hz / 10);
}
sc->busy = 1; /* :373 */
err = npunwa_transact(sc, op, sub, port, pl, npl, reply, nreply);
sc->busy = 0;
wakeup(&sc->busy); /* :376 */
return (err);
}
and its own comment says why it exists before it was ever needed:
"The window holds exactly one transaction, and now that the wait above releases the mutex there is a gap in which a second caller could start one. There is only one caller today - the task below - but a mailbox that is single-writer by luck rather than by construction is not worth the next person's afternoon." -
contrib/npuep/npunwa.c:356
npuep wrote that with one caller. octep has six.
Why msleep and not unlock/pause/lock¶
This is the part that is not merely tidier, and npuep paid for it:
"msleep releases the mutex for the duration of the sleep. pause does not, and that difference is the whole of "panic: sleeping thread holds npunwa": any other thread that then blocks on this mutex walks into propagate_priority(), finds the owner asleep, and takes the machine down. It took an unload racing the link poll to show it, which is to say it took four hours of not showing." -
contrib/npuep/npunwa.c:185
octep does not have that panic: it unlocks by hand before pause at
mtx_unlock(&sc->mtx); (contrib/octep/octep_nwa.c:194). But the two are not equivalent in the
other direction. msleep drops and reacquires atomically and on a wait channel; unlock-pause-lock
drops, sleeps unconditionally for the full tick, and reacquires. Three consequences:
- The gap is a real window that nothing can be gated on, which is section A's third race.
- Nothing can wake it early.
npuep's teardown doeswakeup(&sc->busy);(contrib/npuep/npunwa.c:1027), commented "whatever is mid-wait gives up now", and the mid-wait caller returns at once.octepsleeps out its tick regardless, so a detach waits for the full remaining timeout - up to four seconds per outstanding request on a path whose cost is already recorded as "about 12 ms when the far side is healthy and up to four seconds when it is not" (docs/npuep/netagent.md:166). - It cannot notice that it should stop.
npuep's wait checks two things each tick thatoctep's does not:if (v == 0xFFFFFFFFU)(contrib/npuep/npunwa.c:178), the endpoint having stopped decoding, andif (!sc->running)(:180) - "detach is waiting; do not make it wait".
And the other half: pass the arguments¶
npuep's transaction takes everything as parameters -
npunwa_transact(struct npunwa_softc *sc, uint32_t op, uint32_t sub, uint32_t port,
(contrib/npuep/npunwa.c:337), with the payload as const uint32_t *pl, int npl and the reply as
uint32_t *reply, int nreply. There is no request block in its softc and no result block either.
The staging race cannot be written.
This is exactly what #224's own lesson asks for - "Either the lock belongs to the caller or the
arguments do" - and npuep does both: the arguments are arguments and
mtx_assert(&sc->mtx, MA_OWNED) (contrib/npuep/npunwa.c:365) puts the lock in the caller.
D. The patch sketch¶
Design. Not built, not compiled, not measured. Four changes, in dependency order.
1. octep_nwa_wait() sleeps on a channel instead of unlocking by hand.
contrib/octep/octep_nwa.c:187. Replace the three lines at :194 with
msleep(&sc->nwa_busy, &sc->mtx, 0, "octepnwa", hz / 100), and add the two early exits npuep
has: the 0xFFFFFFFF endpoint check and a !sc->nwa_running check so detach is not made to wait.
This change alone is a strict improvement and depends on nothing else.
2. Add the one field - int nwa_busy; beside the existing NetAgent state in
contrib/octep/octep.h:1651 - and gate the transaction on it, in the shape at
contrib/npuep/npunwa.c:367. wakeup(&sc->nwa_busy) on teardown, as at
contrib/npuep/npunwa.c:1027.
3. Turn the request block into arguments. octep_nwa_do_request(sc) becomes
octep_nwa_xact(sc, op, sub, port, param, param2, reply, nreply, &status) or similar, and
contrib/octep/octep.h:1668 through :1672 are deleted. The five internal callers pass what they
already set; each of them currently writes five fields and then calls, so each becomes one call.
4. Keep nwa_last_* as a diagnostic, and only that. The nwa.request sysctl and nwa.last
exist to send a request by hand and read what came back, and that is worth keeping - it is how
bringup.sh reads a port's MAC, and the repository names it as the way to find out whether a
firmware refuses an attribute (nwa.request, contrib/octep/octep_dp.c:8405). So the sysctl path keeps writing the
nwa_last_* block under the lock, as the last transaction, which is what it is called and what it
honestly is. The five internal callers stop reading it and take their results through arguments. The
race disappears not because the block is locked but because nothing that matters reads it any
more.
Which lock, explicitly¶
Not a new mutex and not an sx. sc->mtx, held by the caller across the whole transaction, with
nwa_busy as the serialiser.
The reasoning against the alternatives:
sc->mtxalone cannot do it, and the issue is right about that: it is dropped inside the wait, which is where the four seconds go. But the conclusion is not "use a different lock" - it is "the mutex protects the flag, and the flag serialises the transaction". That is what a condition variable is, andmsleepon an address is FreeBSD's cheapest form of one.- A second mutex would have to be held across
octep_nwa_xfer(), which sleeps - so it would be a sleepable mutex held across a sleep, and it introduces a lock order (nwa_lockbeforesc->mtx) that every future caller has to get right. - An
sxcan be held across a sleep, and it still only covers callers that take it. The window is also touched byoctep_nwa_probe()andoctep_nwa_release(), the latter defined atcontrib/octep/octep_nwa.c:403, which are reached from insideoctep_nwa_do_request()and would need the same discipline. A flag checked by everything that touches the window is narrower and harder to get wrong.
What is not here¶
- Not built and not measured. No claim that this compiles, and no number.
- No reading of
octep_nwa_xfer()'s internals. The design assumes it is the only thing that writes the window during a transaction. That was not verified line by line, and it is the assumption thebusyflag's placement rests on. - Nothing about
octep_nwa_release(). It is called at(void)octep_nwa_release(sc, 1);(contrib/octep/octep_nwa.c:404) before every request, and #227 is open about how long it waits. That issue needs a measurement on a healthy mailbox and is deliberately not answered here. (It has one now: the-second-acknowledge.md - tens of microseconds, and the wait moved inside the transaction.) Whetherreleaseshould be inside or outside thebusygate depends on what it does, and it is the one ordering question this sketch leaves open. - No count of how long the bring-up path and the poll actually overlap. Section B argues the overlap exists; it does not measure it.
What this needs that is not in the repository¶
Nothing. This one is answerable entirely from the two drivers in this tree, which is why it is the only page in this set with no questions for the vendor source - and it is worth saying, because it means the cost of fixing it is review time rather than another round of reading binaries.
The one thing it needs from the appliance is the same thing every change here needs: a build on the
appliance against its own kernel, and a run with the link poll and a hand-issued nwa.request
overlapping on purpose, which is the case that currently has no test.
Lesson. A sibling driver's scar is cheaper than your own: npuep recorded a panic, a four-hour
hunt and a race it fixed while it still had one caller, in comments beside the code that fixes them -
and octep grew to six callers of the same mailbox without any of it, because nothing makes a reader
of one driver open the other.
The discussion of this page is held in the project's GitHub Discussions. Showing it here loads giscus from giscus.app and GitHub, and writing in it needs a GitHub account - what that means.