XGS 3300 - what has been measured on it¶
Numbers taken on one appliance, with how each was taken and what it does not mean. They are here rather than in octeon-tx-reference.md because a measurement belongs to a board, not to a family: a second XGS 3300 with a different kernel or a different cable would produce different figures, and the reference page should carry conclusions rather than readings.
Nothing here is a projection. Every line was read off this machine.
The machine¶
| appliance | Sophos XGS 3300, assembly AMDA0202-0004, platform xgs1us |
| host | AMD Ryzen Embedded V1780B |
| coprocessor | Cavium OCTEON TX CN8365, PCI 177d:a300 at pci0:1:0:0 |
| operating system | OPNsense 26.7 / FreeBSD 15.1-RELEASE-p1 stable/26.7-n283674-12334a596709 SMP until 2026-10-03; OPNsense 26.7.5 / FreeBSD 15.1-RELEASE-p3 stable/26.7-n283949-083dc7025377 SMP kernel.old with a module of its own); OPNsense 26.7.7 / FreeBSD 15.1-RELEASE-p4 stable/26.7-n284159-abbbe67a45e7 SMP, read 2026-10-17 - the README's badges carried |
| driver | octep, built on the appliance against that kernel's own headers |
| front ports | 12, all presented as oxp0..oxp11 |
Round trip and loss¶
Method. ping -f from the appliance to the router on the other end of a copper panel port's
cable, 30,000 full-size frames, interface counters read either side.
elapsed 6 s
in 30022 pkts 1512 bytes/pkt 5003 pps 60 Mbit/s
out 30003 pkts 1513 bytes/pkt 5000 pps 60 Mbit/s
0.0% packet loss
round-trip min 0.091 avg 0.193 max 0.508 ms
What it is. A latency figure and a loss figure. Nothing dropped in 30,000 full-size frames.
What it is not. A throughput figure. ping -f sends the next probe when the last reply arrives,
so it is bound by the round trip: 5,000 packets a second against a 0.193 ms round trip is the same
number written twice.
The transmit ceiling¶
Method. A UDP sender that never waits (blast.c, below), aimed at a panel port with no cable
in it so the frames cross the whole host-to-coprocessor path and die at a dark switch port without
touching anything anyone else is using. Five seconds, 1472-byte payloads. The coprocessor's own
named counters were read either side of the run over the rpc facility.
offered and accepted by the driver 2,628,609 525,722 pps
FPCNTR_RX_KN +2,627,710 the coprocessor took them
FPCNTR_FROM_KN_TO_WIRE +2,628,609 and chose the wire for every one
FPCNTR_TX_DROP_QUEUE_FULL +2,210,885 84% refused at its egress queue
to the wire 416,825 83,365 pps = 1,023 Mbit/s
The account closes. 1,023 Mbit/s is line rate for a gigabit port at 1534 bytes a slot with the preamble and the interframe gap, so everything the driver posted either left at line rate or was dropped by the coprocessor's egress queue - which is the correct thing for it to do.
So the number that matters is what the driver accepts:
| packets a second | ||
|---|---|---|
| the driver accepts | 525,722 | measured |
| a 1G port needs | 81,486 | 6.5x headroom |
| a 10G port needs | 814,863 | 65% of line rate |
For the eight copper panel ports and the two 1G cages this driver is nowhere near the bottleneck. Only the two 10G SFP+ cages are capped.
Read that table one port at a time. It says what the driver accepts for a single interface, and that is what it was measured as. The appliance's total is a different number and a smaller one - see "the aggregate ceiling" below, which was measured afterwards and is one gigabit for all twelve ports together.
Removing a redundant write¶
Method. The same generator and the same dark port, before and after the change in #173, which stopped the transmit path zeroing the frame it was about to copy over.
| payload | before | after | |
|---|---|---|---|
| 1472 bytes | 525,722 pps | 535,142 pps | +1.8% |
| 64 bytes | 595,558 pps | 607,232 pps | +2.0% |
What it tells us beyond the gain. 525,722 packets a second is 1.9 microseconds each, and a redundant 1,514-byte write is not what that is made of. The next suspect is the one MMIO write per packet - the input doorbell.
What load on one front port costs the other eleven¶
Method. tools/octep-fairness.sh. The generator is paced now - blast takes a packets-per-second
argument and sends in bursts of about a millisecond - so the load is a known rate rather than
whatever one core can manage. Each step offers six seconds of traffic at one rate on a dark front
port while an ordinary ping runs underneath it on the port that carries the appliance's WAN,
and the coprocessor's own named counters are read either side of every step.
with no load at all: 0.0% loss, 0.177 ms average, 0.209 ms worst
target_pps offered_pps wire_Mbit wan_loss wan_avg wan_worst drop_queue_full
5000 5000 61 4.2% 0.168 0.228 0
10000 10000 123 0.0% 0.169 0.203 0
25000 25000 307 8.3% 0.170 0.207 0
50000 50000 614 29.2% 0.173 0.225 0
100000 100000 1227 31.2% 0.173 0.250 100,229
200000 200000 2454 33.3% 0.171 0.187 696,534
400000 400000 4909 72.9% 0.183 0.228 1,904,034
unpaced 535176 6568 60.4% 0.173 0.193 2,711,514
with no load again: 0.0% loss, 0.178 ms average, 0.225 ms worst
What it says. The twelve interfaces are not independent. A flood aimed at one of them costs the
others their packets, and it starts long before anything is reported as dropped: at 50,000 packets a
second the WAN loses 29% of its echoes while FPCNTR_TX_DROP_QUEUE_FULL has not moved at all.
Below 10,000 packets a second the loss is indistinguishable from this appliance's ordinary
background, which is why the soak script defaults to that rate.
And the latency never moves. 0.17 ms average at every rate, 0.25 ms at worst, the same as idle. A frame for another port is sent at once or thrown away; it is never delayed. Whatever is full is not a queue deep enough to wait in.
Where those packets are lost, counter by counter¶
Method. 200 echo requests at 50 a second, under three conditions, with five instruments read
either side: the interface's own transmit count, the coprocessor's FPCNTR_RX_WIRE and
FPCNTR_FROM_WIRE_TO_KN_FORCED, the driver's dp.rx_done, and the host's ICMP input histogram.
| requests posted | coprocessor took from the wire | forwarded to the host | driver delivered | echo replies at the stack | loss | |
|---|---|---|---|---|---|---|
| no load | 202 | 210 | 210 | 218 | 200 | 0.0% |
| 50,000 pps elsewhere | 202 | 161 | 161 | 153 | 126 | 37.0% |
| unpaced elsewhere | 200 | 72 | 72 | 72 | 55 | 72.5% |
Four things follow, and they narrow the fault to one place:
- The host posts every request in all three runs. Nothing is refused on this side, and
net.inet.ip.intr_queue_dropsstayed at zero throughout. - The coprocessor forwards everything it receives.
RX_WIREandFROM_WIRE_TO_KN_FORCEDare equal in every run, so no LIF gate and no TCAM entry is dropping anything. - But it receives less and less from the wire as the load rises - 210, then 161, then 72. The replies are not coming back, because the requests are not getting out.
- A second, much smaller loss is inside the host: 161 forwarded against 153 delivered.
So the frames die after this host counted them as sent and before the gateway ever saw them, on a path every front port shares.
The suspect, found by reading the transmit path afterwards rather than by guessing: every
interface copies its frame into one buffer - sc->dp_txbuf, a single page - posts an
instruction carrying that buffer's physical address, rings the doorbell and releases the mutex. The
coprocessor reads the memory itself, afterwards, on its own schedule. The next frame overwrites the
previous one. At 1.9 microseconds a packet an echo request posted by one port is overwritten by
another port's traffic before it is read, and what goes out in its place carries the other port's
tag. That would produce exactly this: posted, counted, never seen, and no counter anywhere
reporting a drop. It was built, and the next section is the same sweep again.
The same sweep, with a buffer per instruction slot¶
Method. Identical to the sweep above - same script, same rates, same appliance, same kernel -
with one change in the driver: sc->dp_txbuf, one page shared by every interface, became
sc->dp_txbufs, 256 buffers at the ring's own stride, and a frame is copied into the buffer
belonging to the slot its instruction will occupy. Nothing else was touched. The appliance was
rebooted because the datapath attaches once per coprocessor boot, and it came back with twelve
interfaces, the WAN lease and the default route, unattended.
with no load at all: 0.0% loss, 0.178 ms average, 0.229 ms worst
target_pps offered_pps wire_Mbit wan_loss wan_avg wan_worst drop_queue_full
5000 5000 61 0.0% 0.169 0.210 0
10000 10000 123 0.0% 0.172 0.245 0
25000 25000 307 0.0% 0.170 0.219 0
50000 50000 614 0.0% 0.174 0.385 0
100000 100000 1227 0.0% 0.181 0.240 100,274
200000 200000 2454 0.0% 0.175 0.255 698,090
400000 400000 4909 0.0% 0.173 0.200 1,890,791
unpaced 528885 6490 0.0% 0.173 0.203 2,660,027
with no load again: 0.0% loss, 0.180 ms average, 0.224 ms worst
| offered on one port | WAN loss before | WAN loss after |
|---|---|---|
| 5,000 pps | 4.2% | 0.0% |
| 25,000 pps | 8.3% | 0.0% |
| 50,000 pps | 29.2% | 0.0% |
| 400,000 pps | 72.9% | 0.0% |
| unpaced, about 530,000 pps | 60.4% | 0.0% |
Zero at every rate, including 6.5 Gbit/s offered at one port while another carries the appliance's internet. The twelve interfaces are independent now, and they were not before.
A second counter agrees without being asked. FPCNTR_TX_WIRE_ERR used to move whenever
anything was loaded - 25 to 76 of them in a fifty-second window at a mere 10,000 packets a second.
On the fixed driver it does not move at all, through 16 million frames in a thirty-second window at
full rate. Frames that had been overwritten mid-flight were reaching the MAC malformed, and the far
side was counting them; now there are none to count.
What this says about the earlier numbers. The egress ceiling is unchanged - drop_queue_full
tracks the old run step for step - so the one-gigabit aggregate below stands exactly as measured.
What changed is that reaching it no longer costs the other eleven ports their traffic.
The aggregate ceiling is one gigabit, not twelve¶
Method. Two dark front ports loaded at the same time, six seconds each, and the arithmetic of
FPCNTR_FROM_KN_TO_WIRE minus FPCNTR_TX_DROP_QUEUE_FULL - what the fast path chose to send, less
what it threw away for want of room. The soak was running concurrently at 10,000 packets a second on
a third dark port, which is included in these figures.
| offered | chosen for the wire | refused, queue full | reached the wire | |
|---|---|---|---|---|
| one port at 50,000 pps | 329,588 | 0 | 329,588 | 54,931 pps |
| two ports at 50,000 pps | 664,182 | 159,759 | 504,423 | 84,070 pps = 1,023 Mbit/s |
| two ports at 200,000 pps | 2,462,180 | 1,960,315 | 501,865 | 83,644 pps = 1,019 Mbit/s |
One port at 614 Mbit/s passes untouched. Two ports at 614 Mbit/s each do not: a quarter of the frames are refused, and what gets through is 1,023 Mbit/s - the same figure the single-port ceiling run produced, and the same figure again when the offered load is quadrupled. One gigabit is not a per-port limit on this appliance. It is the whole datapath.
It is a bit rate, not a packet rate, which is worth proving because the two look identical from one frame size:
| payload | ports | reached the wire | |
|---|---|---|---|
| 1472 bytes | two at 200,000 pps | 82,582 pps | 1,013 Mbit/s |
| 64 bytes | two at 200,000 pps | 405,964 pps | 422 Mbit/s, nothing refused |
| 64 bytes | one, unpaced | 591,380 pps | 615 Mbit/s |
| 64 bytes | two, unpaced | 1,053,385 pps | 1,096 Mbit/s |
A million small frames a second go through the stage that caps full-size frames at 84,000. In bytes the two agree: 129 MB/s and 137 MB/s. So the shared stage drains about a gigabit of frame bytes a second whatever the frames are.
What it is not. It is not a limit of this driver: the coprocessor accepted 6.5 Gbit/s over PCIe
in the same runs - FPCNTR_RX_KN counted every frame - and put a gigabit of it on the wire. It is
not a measurement of twelve ports doing useful work either; every frame here died at a dark port,
and a cabled port has a link partner and flow control that a dark one does not.
And it is not the switch uplink. That was the first suspect and it is now ruled out by reading
the switch itself. xgs-mdio -a 0 2 <port>.0 on the coprocessor returns each port's status
register, and the uplink is the one that differs:
| switch port | status | link | speed field |
|---|---|---|---|
| 0, the uplink to the coprocessor | 0x0f4d |
up | 3 |
| two cabled copper ports | 0x0e0f |
up | 2, which is 1000 Mbit/s |
| the dark copper ports | 0x000f |
down | - |
| the two 1G cages | 0x0249 |
down | 2 |
The only difference between the uplink and a gigabit port is one bit: speed 3 rather than 2, which on this family means above 1000 Mbit/s. The uplink also carries a different connection mode - 13 against 15 - which is what a 10G mode looks like beside a 1000BASE-X one.
So the gigabit is inside the coprocessor's fast path, not on the wire between it and the switch.
The remaining candidate is a shaper: FPCNTR_TX_DROP_SHAPER_INV exists, and the rpc enumeration
has QOS_SET_SHAPER_RATE at 23 and QOS_GET_SHAPER_RATE at 24. Neither request structure is in the
module's DWARF, so reading the rate back is the next piece of archaeology rather than the next
command.
Two counters that were wrong, and how the measuring found them¶
Received bytes were doubled. The first run charged an interface 3,026 bytes for every
1,514-byte frame. ether_input_internal() adds the received byte count itself unless the driver
claims IFCAP_HWSTATS, which this one does not, so counting it in the driver as well doubled it.
The packet count was right, because that file increments no IPACKETS. Fixed in #171; afterwards
the two directions agree at 1512 and 1513 bytes a packet, which is what a ping flood should show.
Opkts is offered load, not what left the port. In the ceiling run it read 6,432 Mbit/s out
of a gigabit port: the 84% the coprocessor dropped, counted as sent. The driver cannot see those
drops per packet - they belong to the far side and only rpc reads them - so read Opkts as
offered load and FPCNTR_TX_DROP_QUEUE_FULL as the truth about what the wire took.
The receive path, and why every number above is a transmit number¶
Everything on this page until now was measured by sending. A generator on a dark front port, thousands of packets a second, hours of soak, and the loss figures that came out of it are real. Not one of those measurements could ever have filled a receive ring, and a defect had been sitting in that half of the datapath the whole time.
What it looked like: the appliance held its lease, routed, resolved, and answered the first hop in
0.6 ms. traceroute ran clean to six hops. And a fetch of a 16.7 MB file returned 0 bytes in
25 s, every time, while three consecutive opnsense-update runs installed nothing at all.
Four measurements say where it is not:
| the line | 16.7 MB to a PC on the same ONT at 4,354 kB/s |
the host stack and igb0 |
64 MB in 737 ms, 90,933 kB/s, with no octep in the path |
| the coprocessor's own ledger | six counters moved during a failing download and balanced exactly: RX_WIRE +180, TX_KN +180, FROM_WIRE_TO_KN_FORCED +180, and no drop counter moved |
| the front port | Ierrs 0 Idrop 0 Oerrs 0 |
So the wire was fine, the host was fine, and the coprocessor believed it had delivered everything.
The two numbers that had never been put side by side¶
The far side's count of what it handed over, against the host's count of what it took:
FPCNTR_TX_KN +19 handed to the host
driver dp.rx_done +0 taken by the host
all oxp interfaces Ipkts +0
dp.state then showed real WAN frames sitting in host memory, addressed to the WAN port's own MAC,
with OUT_CONTROL reading idle - the block had written them and finished. R_OUT_CNTS low half
read 1,532: packets the host had never acknowledged. And all eight MSI-X vectors were frozen,
their sum exactly equal to dp.intr_taken, so not one interrupt had fired since the stall began.
One dp.service=1 by hand delivered 1,550 frames in a single call. With a service loop running,
the first hop answered 8/8 at 0.0% loss, and the interrupts resumed on their own.
The mechanism¶
octep_dp_intr took one pass at the ring. A pass reads R_OUT_CNTS, takes at most RSIZE
packets, and acknowledges those. The block raises its interrupt when the count crosses the
interrupt level - not while it sits above it. So a burst bigger than one pass leaves the count above
the level with nothing left to re-cross it, and the ring is dead.
And octep_dp_service() had exactly two callers: that handler, and a sysctl written by hand. There
was no periodic receive path, so a lost edge was not a hiccup - it was permanent, until a reboot.
That is the whole symptom set. A ping never fills a ring, so a ping always worked. A download fills one immediately, so a download never worked - and from that moment reception was dead for everything, which is why the ping that had just succeeded started failing too.
After the fix¶
The handler now drains until the ring reads empty, bounded at OCTEP_DP_OQ_DRAIN_ROUNDS, and a
callout services every armed ring twenty times a second whatever the interrupts are doing.
(Every servicer drains now and the bound is in packets; and that callout, whose service was one
pass, turned out to be a way of leaving a ring with packets in it: a visit is not a pass.)
The same fetch, on the same appliance, over the same front port:
and the instruments for that one download:
dp.intr_drained |
1,824 | handler entries that needed a second pass |
dp.rxwd_runs |
11 | interrupts genuinely lost, and caught by the timer instead |
R_OUT_CNTS low half |
0 | the ring fully drained |
rxwd_runs is the number worth keeping an eye on. Eleven lost edges in one 16 MB download is not a
rounding error, and before there was a timer under them, eleven was ten more than it took.
And rxwd_runs was read too strongly too. Eleven is how many times the timer found packets in
a ring, and that is all it is. It finds some in a ring whose handler is on its way; and for as long
as its own service was one pass it was itself what left a ring with packets in it, to be found on
its next tick. No lost interrupt has been shown on this block. The number to keep an eye on is
dp.rxwd_stalls: a visit is not a pass.
And intr_drained was read too strongly when it was first published here. This page said every one
of those 1,824 second passes was a stall under the old code. It was not: a second pass that finds three
more frames is an ordinary second pass, and a stall needs the count left above the interrupt level
after a pass. What the counter measures is how often one handler entry needed more than one pass,
which under load is most of them. The evidence for the stall is the measurement above - TX_KN +19
against dp.rx_done +0, 1,532 unacknowledged, eight vectors frozen - and the figure itself was also
inflated by a second defect, fixed separately: a pass used to report the register's reading rather than
what it had taken, so a round that delivered nothing still counted. (It counts the passes beyond the
first of any servicer's hold of a ring now, not only the handler's.)
Then the thing the stall had been blocking for three attempts: pkg fetch -u took the cache from 40
packages to 336 in 52 seconds, having previously stalled at 41 over several minutes each time, and
pkg upgrade installed all 84 - OPNsense 26.7 to 26.7.5 - with the datapath carrying it and all
twelve front ports still up afterwards. That download alone needed 59,675 second passes.
And with the appliance warm, three 16.7 MB fetches back to back:
run 1: 16,697,068 bytes in 7 s = 2,038 kB/s
run 2: 16,697,068 bytes in 3 s = 4,076 kB/s
run 3: 16,697,068 bytes in 3 s = 4,076 kB/s
4,076 kB/s is the best of the three, against the 4,354 kB/s the same file reaches on a PC on the same ONT. That is 94% of it: the gap narrowed, it did not close. And the runs do not agree closely enough to place it even that precisely - the first of the three was 2,038 kB/s, 47% of the PC figure, and the fetch above them 1,049 kB/s, so one file spread 3,027 kB/s across four readings, eleven times the 278 kB/s between the best run and the PC.
The seconds printed beside those rates are not the rates' own timebase. 16,697,068 bytes at 4,076 kB/s is 4.00 s and at 2,038 kB/s is 8.00 s - both exact, with kB read as 1,024 bytes - against the 3 s and 7 s printed beside them, and the fetch above them reads 29 s where its rate implies 15.5 s. So the rates are averages over the transfer and the seconds are something coarser. No transcript was kept to settle which, so read the rates and treat the seconds as indicative.
What these runs establish is that a download now completes, repeatedly, at the same order as the line - #197 is gone. What they do not establish is a throughput figure for the datapath: 4,076 kB/s is 33 Mbit/s on a gigabit front port, so what bounds these runs is the ONT and the far server. Nor a clean receive path - the driver was still returning sixteen credits per packet where the block debits about one, and a twenty-download loop still stalled on the tenth until #205 held the credit to the grant.
A kernel update must not cost the front ports¶
This is the circle, and it closed on this appliance on 2026-10-01:
new kernel -> the module's stamp no longer matches -> bring-up refuses it -> no front ports
-> no WAN -> nothing can fetch the kernel sources that would rebuild the module
The refusal itself is right. A FreeBSD module declares its kernel dependency as a range, so within
15.x the loader accepts a module built against a different 15.x kernel rather than refusing it,
and any structure that moved is then read at the wrong offset inside the kernel of a machine
carrying traffic. There is no loud failure to wait for. So the module is stamped with the kernel it
was built against, and a mismatch is refused.
And the refusal costs more than the link. OPNsense configures its interfaces from the devices
that exist at that point in the boot, and the assignments whose devices are absent are dropped out
of /conf/config.xml outright. One boot with the module refused removed wan, opt1, opt2 and
opt3 from the configuration here - the WAN's own DHCP assignment with them - and getting the module
right afterwards did not put them back. They were restored from /conf/backup/, which is the only
reason this is a paragraph and not a reinstall.
The window¶
Between a kernel being installed and the host rebooting onto it, everything needed is present at the same time:
| the old kernel is still running | so the module is loaded and the WAN is alive |
| the new kernel is already on disk | where strings -a /boot/kernel/kernel finds the source commit it was built from |
That is the only moment a kernel change can be followed, and install/kernel-follow.sh is that moment
made automatic. Where to call it from took two attempts, and the first would never have run.
The hook level, which was checked rather than assumed¶
rc.syshook.d/update looks like the obvious home: the directory exists and OPNsense ships a script in
it. Nothing on this appliance passes update to rc.syshook. The callers pass facility, stop,
start, monitor, upgrade, import, early and carp. A hook placed at that level is read by
nobody - which is the most expensive kind of wrong, because it looks like a guarantee and it is a file.
upgrade is called, from scripts/firmware/upgrade.sh, and reading that script settles where in the
sequence it sits:
opnsense-update -u the packages
rc.syshook upgrade <-- the hook
opnsense-update -K -c ; -K the pending kernel is applied to /boot HERE
reboot
So the new kernel is not in /boot when the hook runs. It is a set waiting in
/var/cache/opnsense-update/.sets.pending/ with a marker beside it, and once it is applied the reboot
follows with no hook in between. Reading /boot/kernel/kernel is therefore too late at the one place
anything calls us.
So the follower looks in both places, staged first:
a staged set, kernel-<release>-<arch>.txz |
tar -xOf <set> boot/kernel/kernel \| strings reads its version without unpacking it; the pipe closes at the first match |
/boot/kernel/kernel |
the kernel the next boot will use, for when -K has already run |
And a cron entry, which needs nobody's cooperation¶
Five minutes, from src/etc/cron.d/octep. When the stamp already matches - almost always - it is two
file reads and an exit. It is there because a hook depends on somebody calling it and this does not,
and because the update level proved that assumption worth paying for once. That FreeBSD's cron reads
/usr/local/etc/cron.d on this appliance was also tested rather than assumed: a probe entry written at
19:06:30 fired at 19:08:00.
Then one reboot brings the kernel and the module up together and nothing is done by hand.
Run by hand it is a no-op whenever the stamp already matches, and DRY=1 says what it would do:
It fails towards the ports, not away from them¶
- the build happens before anything is replaced, and a build that fails leaves the installed module and its stamp exactly where they were
- the hook exits 0 on every path, because an update that stops half way is worse than a module that needs rebuilding by hand; what a failure costs is a loud warning in the log
contrib/octep/build.shtakesKVERso the stamp records the kernel being built for rather than the one running, which is the whole point before a reboot
And following forwards must not mean falling off backwards¶
The new module is stamped for the kernel on disk, which is not the kernel running while it is built. If the host boots the old kernel again - a rollback, a boot menu choice, an update that did not take
- that stamp is wrong in the other direction and the ports would be refused for the opposite reason to the one they were being saved from.
So the module being replaced is kept beside it as octep.ko.prev with its own stamp, and the
bring-up script loads that one when its stamp is an exact match for the running kernel. This is not
FORCE: nothing is loaded on a guess, and a module that matches neither kernel is still refused.
Tested¶
Proving the refusal branch used to mean unloading the driver, which on this appliance takes the
twelve interfaces, the WAN lease and the default route with it and needs a host reboot to undo. Two
small changes make it reachable instead: bringup.sh takes CHECK=1, which stops after the module
decision and says which one it would load, and the loader commands are variables so a test can
report the module absent without it being absent.
All seven on the appliance, with the datapath carrying traffic throughout:
| stamp already matches | no-op |
DRY=1 on a mismatch |
reports, changes nothing |
| mismatch, for real | the previous module kept with its stamp, the installed one stamped for the kernel on disk - and the loaded driver was never touched, WAN at 0.0% loss during |
octep.ko wrong, octep.ko.prev an exact match |
CHECK=1: would load /boot/modules/octep.ko.prev, exit 0 |
| neither stamp matches | refused, exit 1 |
| a staged kernel set present | version read out of the .txz, reported as read from: staged, waiting to be applied, and the decision correct with both a matching and a stale stamp |
cron reads /usr/local/etc/cron.d |
a probe entry fired on the next minute boundary |
And that row said rebuilt more strongly than the run could show. Nothing in the follower as it
was merged could tell a rebuild from a reinstall. It built in /root/npu/octep-sdp, a tree assembled
by hand that persisted between builds, and the make clean meant to empty it was given no SYSDIR:
with no /usr/src/sys to fall back on, bsd.sysdir.mk stops with Unable to locate the kernel
source tree, and that error went to /dev/null. kmod.mk never re-points an existing machine,
x86 or i386 link, and the generated bus_if.h, device_if.h and pci_if.h are remade only when
their .m file is newer. So once the objects were newer than anything make could see, make would
compile nothing and the module already in place would be installed and stamped for the new kernel -
the silent wrong-offset load the stamp exists to prevent - and the only checks were that make
exited 0 and that an octep.ko was there. #205 found exactly that on the appliance, by review: a
module stamped p3 whose machine link pointed into the p1 tree.
And the one case the follower exists for was not among the seven. fetch-sources.sh printed its
SYSDIR= line at column zero only on the path where the tree was already there; where it had just
fetched one it printed it inside an indented hint, and the follower read it with an anchored sed.
So a run whose sources had to be fetched could only end in could not get kernel sources, and the
seven runs above all had the tree present already. The next day, on the one kernel it had to fetch
for, it fetched 341 MB, verified the tree, reported that it could not get the sources and left the
module unbuilt. Both ends were fixed in #205.
Stopping the receive path before an interface is freed¶
Found by review rather than by a crash, which is the only reason it is written down calmly.
A servicer reads dif->ifp, stores it in each mbuf's rcvif, and hands the chain to if_input().
Detaching an interface calls ether_ifdetach() and then if_free() on that same ifnet. Nothing
connected the two, so a detach could free an ifnet while a servicer still held mbufs pointing at it -
inside the kernel of a machine carrying traffic.
Two things made it reachable. octep_dp_stop() detached the interfaces before releasing the
interrupt handlers and the watchdog, so both servicers were live for the whole of the detach. And
if_input() was called after the ring's busy flag was released - deliberately, so the ring was
free while the stack worked - which meant the busy flag could not be used to tell whether a servicer
was still holding an ifnet.
Every path that reached it is deliberate: dp.stop, dp.if_del, a device detach, kldunload octep.
Normal operation never goes near it. "You have to ask for it" is not the same as safe.
The quiesce¶
Two halves, and both are needed:
| a servicer that has not started | takes the ring's busy flag, sees the quiesce, and gives the flag straight back. The check is after the acquire - before it, one could pass the check and take the flag after the wait had finished looking |
| a servicer already inside | is waited for, by reading the busy flags. For that wait to mean anything, if_input() moved back inside the flag |
Moving if_input() costs nothing. The buffers are re-poisoned and the doorbell is credited before the
frames go up, so the block has its credits back either way, and the only thing held out is another
servicer of the same ring - which is the one thing that must be held out.
The watchdog is drained rather than flagged, and its handler declines to re-arm while a quiesce is
set, so callout_drain() is final instead of racing a re-arm. octep_dp_if_detach_all() quiesces at
its top and resumes at its bottom, so both callers get it and dp.if_del - which leaves the datapath
up - can carry on afterwards.
Testing it without paying for it¶
Reaching the quiesce the way the driver does takes the twelve front ports with it, one of which is the
WAN, and the last time those ports went away for a boot OPNsense dropped four interface assignments
out of its configuration. That is too much to spend on a test, so the mechanism is reachable on its
own: dp.rx_quiesce runs exactly the quiesce the detach path runs, and dp.oq_busy shows the flag and
one busy flag per served ring.
dp.oq_busy also carries the two numbers that answer the question a sample cannot. A servicer is
inside a ring for microseconds, so a reading taken next to one is almost always clear - which is what
the first run of this test showed, and it proves nothing either way. waits counts the quiesces that
had to spin at all, and longest the worst one in microseconds. If waits stays at zero under load,
the wait is decoration; it does not.
What the test found, which was not what it was for¶
The first version of the quiesce was written to close a use-after-free, and the knob was added only so the mechanism could be exercised without taking the front ports away. Hammering it found a second defect, in the resume rather than the quiesce.
5,285 quiesce/resume cycles over ten seconds, with a download offering 1,510 frames a second underneath, delivered four frames. The receive path was dead for the whole run, and the appliance was at 100% loss to the internet immediately afterwards.
The reason is the same hardware behaviour the drain loop exists for. A quiesce turns a servicer away
before it clears R_OUT_INT_STATUS, so every interrupt arriving while the quiesce is set is taken and
discarded with the status still latched - and the block raises its interrupt on the count crossing
the level, not on its sitting above it. Once the quiesce lifts there is nothing left to raise one.
Clearing the status on the way out instead would lose the edge just the same.
So resume has to look at the rings itself, and the first version did not: it armed the watchdog 50 ticks out and returned, and the next quiesce two milliseconds later drained that timer before it could fire. Nothing ever looked.
Resume now drains every ring before it returns, bounded like the handler's own loop, because a quiesce taken under load leaves a backlog one pass cannot clear and no interrupt is coming to finish it. (Sixteen sweeps of one ring's worth from every ring, now; and it arms the watchdog the way the watchdog arms itself, because arming it for its period threw away a request for its next tick. The same test was run again there, 2,782 cycles under a stream that did not slow: a visit is not a pass.)
| 5,285 cycles, before | 4 frames delivered, 100% loss after |
| the same test, after | 21,990 frames delivered, 0.0% loss after, and a 16.7 MB fetch in 4 s |
The lesson is narrow and worth keeping: on this block, anything that declines to service a ring owes a service when it stops declining. The drain loop learned it, the watchdog learned it, and the quiesce had to learn it separately because it looked like a locking problem rather than an interrupt one.
The watchdog had not learned it. Its one pass over a ring the handler was working left that ring with packets in it and nobody inside, fifty milliseconds at a time, and the handler that found the ring held was declining with nobody owing. It showed as one TCP stream through the host that would not go faster than 75 Mbit/s: a visit is not a pass.
And the number the mechanism needed¶
waits 74 on that run, longest 60 us. Seventy-four times in ten seconds a quiesce found a servicer
already inside a ring and had to wait for it - which is the measurement that says the wait is
load-bearing rather than decoration, and the one a sample of the busy flags could never have given.
Without it, those seventy-four interfaces would have been freed under a servicer holding mbufs that
pointed at them.
A bridged front port, and the filter that made one look alive while dropping every reply¶
A front port's hardware filter admits unicast only for the one address that port was given. That is right for a routed port. Put the port in a bridge and it is wrong for every frame that matters: the replies to the machines behind it carry the bridge's address, so none of them is admitted - while broadcast still arrives, so ARP answers and DHCP hands out leases and the port looks perfectly healthy. The symptom points at the firewall rules, the switch, the driver, the cable; not at the one thing that is filtering.
The attribute that lifts it is NetAgent 0x45, and the appliance had been getting it from a line in
the bring-up script that turned it on for ten ports unconditionally at boot. That worked and was
wrong in both directions: a routed port paid for a filter it did not need, and the setting lived in
a shell script where nothing in the system could see it or reverse it.
The mechanism¶
octep now follows IFF_PROMISC. The link poll already runs on taskqueue_thread because the
NetAgent request sleeps; each tick it reads the flag off every front-port interface and sends the
attribute only where the answer changed. The flag is read from the interface rather than
recorded from SIOCSIFFLAGS, so there is no transition to miss and no second copy to go stale.
Two sizing decisions, both measured rather than assumed:
- The link read stays one port per tick - it is paid every second forever, and twelve NetAgent round trips a second on a control channel this project has watched go down under one unlucky request is not worth a faster answer to a question that moves at the speed of somebody plugging in a cable.
- The filters sweep all twelve every tick. Their conditions are false unless the stack has just changed its mind, so in the steady state the sweep costs twelve comparisons and no mailbox traffic at all - and a bridge is built by adding every member at once, so round-robin would have left the last member deaf for twelve seconds.
A refusal is reported once and not retried: a firmware without the attribute answers with a failed status, and asking again every second for the life of the machine is the wrong answer to a clear no.
What it did, on a bridge of eleven of the twelve ports¶
The appliance's own configuration: bridge0 with every front port in it except the one that is the
WAN. So eleven ports want the filter open, one does not, and the driver is told nothing by anybody -
it reads IFF_PROMISC and acts.
[86] oxp0: promiscuous on
[87] oxp1: promiscuous on
[88] oxp2: promiscuous on
[89] oxp3: all-multicast on
[90] oxp4: promiscuous on
[91] oxp5: promiscuous on
[92] oxp6: promiscuous on
[93] oxp7: promiscuous on
[94] oxp8: promiscuous on
[95] oxp9: promiscuous on
[96] oxp10: promiscuous on
[98] oxp11: promiscuous on
| front-port interfaces present | 12 |
| bridge members | 11 |
ports carrying IFF_PROMISC |
11 - the members, and only those |
| promiscuous requests sent | 11 |
| requests refused | 0 |
| requests abandoned after retries | 0 |
oxp3, the WAN: a routed port |
no promiscuous request, all-multicast only, lease held, default route on it |
One port per second, and that is the bound rather than a coincidence. The timestamps are
consecutive ticks: one request per invocation of the link poll, which is what stops twelve ports
from becoming twenty-four sleeping mailbox transactions in a single pass of taskqueue_thread. The
one gap, at [97], is a tick that spent itself on the link read instead.
The same message buffer still held the boot before it, which ran the unbounded version, and the contrast is the measurement:
[88] oxp0: promiscuous on
[88] oxp5: promiscuous on
[842] oxp1: promiscuous on nine ports, one second, one task invocation
[842] oxp2: promiscuous on
[842] oxp4: promiscuous on
[842] oxp6 .. oxp11: promiscuous on
Nine requests inside one tick. Healthy, each costs about 12 ms and nobody notices; on the timeout path each costs up to four seconds, and that is a shared single-threaded taskqueue held for most of a minute by a driver polling a mailbox. The defect was found by review before it was seen, and the log of the boot that had it is the reason the bound is not negotiable.
The attribute was also confirmed to work in both directions before any of this was trusted -
status 0x00000000 for param=0 as well as param=1, on a port with no cable, with the state put
back as found. A mechanism that can only turn a filter on would leave a port permissive forever
after it left a bridge.
Then 30,000 frames through it, to see what the filter costs¶
Method. ping -f -c 30000 -s 1472 from the appliance to a PC behind the bridged port, so every
frame is full-size and every reply is addressed to the bridge. The coprocessor's named counters
read before and after; elapsed time from the shell.
30000 packets transmitted, 30000 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.197/0.328/1.095/0.034 ms
elapsed 10 s
| counter | delta | per second |
|---|---|---|
FPCNTR_RX_WIRE |
29,790 | 2,979 |
FPCNTR_TX_KN |
29,790 | 2,979 |
FPCNTR_FROM_WIRE_TO_KN_FORCED |
29,790 | 2,979 |
FPCNTR_RX_KN |
29,788 | 2,979 |
FPCNTR_FROM_KN_TO_WIRE |
29,788 | 2,979 |
FPCNTR_TX_WIRE |
29,788 | 2,979 |
FPCNTR_TX_DROP |
0 | - |
Interface counters over the same window: oxp0 51.9 MB in and 50.1 MB out at 1,485 bytes a frame,
Idrop 0; dp.tx_iq_full 0; dp.oq_busy reports no ring contention and no quiesce waits.
Three things this says, and one it does not.
- Nothing is lost on either hop. Every counter in the six-stage chain moves by the same number,
twice - once per direction - and the host's own
dp.rx_doneanddp.tx_postedhad read exactly equal toFPCNTR_RX_WIREandFPCNTR_RX_KNbefore the run began. Host and coprocessor agree to the frame. - Promiscuous mode costs nothing measurable. 0.328 ms average with the filter open, against 0.459 ms on a lighter run and 0.63 ms on the first three frames; the spread is scheduling, not the filter. There is no per-frame work in a filter that has been told to admit everything.
FROM_WIRE_TO_KN_FORCEDequalsRX_WIREexactly, every frame, which is the same reading that settled #221: with no flow installed, the fast path forwards everything to the host and accelerates nothing. A bridge does not change that.
It does not establish a ceiling. 2,979 frames a second is lower than the 5,003 further up this page, and the difference is the far end: this run answers a Windows PC rather than a router, 0% was lost, and not one of the driver's pressure counters moved. Nothing here was stressed, so this is a floor on what the path can do and not a limit.
FPCNTR_TX_DROP is 146, and it is from start-up¶
The first reading after the boot showed FPCNTR_TX_DROP and FPCNTR_TX_DROP_QUEUE_FULL both at
146, which reads like a 4.6% loss toward the host. It is not: across a 3,000-frame run and then a
30,000-frame flood, neither counter moved at all. The 146 was spent once, during bring-up, while
the rings were being armed and before the logical interfaces existed - which is where a frame has
nowhere to be delivered by construction.
Worth recording because the snapshot alone would have been read as an ongoing loss, and the only thing that distinguishes the two is a second reading under load. A counter with no derivative is a fact about the past.
Jumbo frames are not available, and the number is 1506¶
Jumbo frames are not available, and the number is 1506. Struck 2026-10-12: a port's MTU is
any value from 72 to 9,000 since that day, the coprocessor having always written a long frame over
as many buffers as it needs - see the MTU is the LIF's and
the rows below. The arithmetic that follows was right
about one buffer and wrong about the limit; it stays as it was written.
Asked for directly, and the answer is in the ring rather than in the hardware. The receive ring is
programmed with one buffer size, which the live register dump confirms as BSIZE 1602:
OCTEP_DP_BUF_SIZE = 1536 + 66 (the private tag) = 1602
OCTEP_RX_PREFIX_LEN = 82 (the far side writes its own prefix into the same buffer)
Ethernet header = 14
OCTEP_DP_IF_MTU_MAX = 1602 - 82 - 14 = 1506
So ifconfig oxpN mtu 1506 is accepted and 1507 is not, and mtu 9000 cannot be honoured by asking
for it: the far side would write a 9,000-byte frame plus an 82-byte prefix into a 1,602-byte buffer.
Jumbo frames on these ports are a ring-sizing change - a larger BSIZE published to the coprocessor
at ring-arm time, which is latched once per coprocessor boot - and not a configuration setting.
Recorded because the clamp has been wrong once already in the other direction: it read
OCTEP_DP_BUF_SIZE - 128 = 1474, below the 1500 every one of these interfaces actually runs at, so
mtu 1500 failed with EINVAL and a port lowered to 1474 could not be raised again without a
reboot.
Ten hours at light load¶
2026-10-08 16:24 to 2026-10-09 02:24 by the appliance's clock, module 73d533b9 - main after
a ring that fills. The trigger on (dp.auto 1), with making between
polls, reviving and the keep-alive at their defaults, and ipsec.flows 0. Those four had never run
together for longer than 33 minutes, and it was the first long run of the receive path of the two
changes before it. It is written here some hours after it ended and not when it ended; the first
of what it found had been posted on two issues the evening it began.
How. A script on the appliance read the driver's counters, five of the coprocessor's and pf's state-mismatch count once a minute, 600 times, and would have switched the trigger off had five commands timed out or twenty been refused. A machine on the LAN, out through NAT by the WAN front port, ran one cycle every ten minutes: a download from a public server cut after six seconds, five small fetches, twenty DNS questions. One connection at a time, sixty-four cycles.
What else used the appliance meanwhile, because it shows in the lines. The measuring machine's own traffic, which reaches the internet through the appliance's management port. About twenty runs of the candidate-table tests of the next section, in the first hour and a half: they are most of the 565 connections made in the first hour. And three compiles of a branch, none of them installed.
| samples | 600 of 600; no panic; the guard never tripped |
| commands to the coprocessor | 0 timed out, 0 refused; dp.flow_rc_refused 0, dp.auto_full 0 |
connections made (dp.auto_made) |
1,958, of them 321 between polls (dp.fast_made); 374 directions attached later (dp.flow_attached) |
| taken out | 681 because pf no longer had their state (dp.auto_gone), 903 found reclaim-pending on the far side (dp.flow_pending); the far side agreed to reclaim 1,584 (dp.flow_reclaimed). The most held at one sample: 87 |
keep-alive touches (dp.ka_touched) |
5,596 |
attempts put off for a pf state not settled (dp.flow_unsettled) |
20,736 |
| frames the coprocessor forwarded without the host | 2,166,821 |
frames the host took (dp.rx_done) |
629,352; the most the host carried at a sample 7 Mbit/s |
dp.rxwd_stalls, dp.oq_lapped, dp.rx_resync, dp.rx_short, dp.oq_unseen |
0 |
dp.oq_bound, dp.credit_capped |
did not move (3 and 1,151 when it began) |
rings the watchdog found work in unasked (dp.rxwd_rescues) |
2,035 |
| given back by the retransmission rule | 1 (the coprocessor's own count), revived in place (dp.flow_revived 1) |
dp.flow_tuple_mismatch |
0 to 30, every one an ICMP echo read from its reply: issue 326 |
| pf's state-mismatch counter | +81 |
| the candidate table's side entry | armed 721 times, read 42, let go at its limit 83: issue 322 |
Per hour, as differences:
| hour beginning | made | between polls | attached | pf's state gone | reclaimed | kept alive | forwarded without the host |
|---|---|---|---|---|---|---|---|
| 16:24 | 565 | 82 | 32 | 440 | 533 | 1,062 | 294,195 |
| 17:24 | 97 | 14 | 11 | 18 | 89 | 375 | 203,977 |
| 18:24 | 124 | 25 | 18 | 26 | 104 | 415 | 246,751 |
| 19:24 | 153 | 31 | 55 | 25 | 99 | 428 | 225,816 |
| 20:24 | 186 | 33 | 51 | 30 | 133 | 540 | 274,995 |
| 21:24 | 146 | 29 | 57 | 21 | 90 | 403 | 257,918 |
| 22:24 | 183 | 26 | 60 | 28 | 123 | 542 | 128,808 |
| 23:24 | 179 | 23 | 23 | 33 | 156 | 660 | 177,067 |
| 00:24 | 166 | 25 | 26 | 27 | 140 | 564 | 176,192 |
| 01:24 | 159 | 33 | 41 | 33 | 117 | 607 | 181,102 |
The load, as the machine on the LAN saw it.
| downloads | 64 begun; 60 carried data until the six-second cut, 0.9 to 83 MB each, 45 MB on average; 4 received nothing - three never connected, one connected and received nothing in its six seconds |
| small fetches | 320; 5 failed |
| DNS questions | 1,280; 9 unanswered |
| a TLS failure of the kind in issue 279 | none |
| downloads in which a connection was given back and revived | 1 of 64: issue 315 had read one in ten |
The four downloads that received nothing were at 20:26, 22:32, 23:11 and 00:38. The router this
appliance's WAN is a client of keeps a monitor on its own upstream gateway, and its log has an
alarm standing at each of those four moments: delay rising to down at 20:26; down from 22:30 to
22:33; down from 23:10 to 23:12; down from 00:38 to 00:42. All five failed fetches are in the last
two of those cycles. The appliance's counters show nothing in those minutes but
dp.flow_unsettled rising, which is opening frames being sent again. So they are put down to the
line, on that evidence and no other. That monitor raised an alarm thirty-three times in those ten
hours and read down in thirty of them.
(The private record of this run said "64 downloads, every one ended by the cut" and named two of the four. It was read from curl's exit status, which is the same for a download that is cut and for one that never connects. The byte counts were in the same log.)
What it does not say. It is ten hours at a handful of connections, on a line of about sixty
megabits. It says nothing about load. And it could not have seen a receive ring that was behind
the block - the fault of a packet is not a buffer, which this
build had: no counter moved for that, and dp.state was not read when the run ended. One new
connection in eight failing would have shown in the fetches, and did not; with eighty-seven
connections at the most the messages that cause it are seldom long enough. That is an argument,
not a reading.
The candidate table with more than one user¶
2026-10-08, module 73d533b9, during the first hours of the run above.
Issue 322, which was read and not run
when it was written. These rows are what main does; the page that will describe what replaces
the side entry is not written, because the change is not merged. The first of them were posted on
the issue the same evening and the rest waited for that page, which is how they came to be late.
How. UDP through NAT from a machine on the LAN to a public resolver, a question and its answer
every twenty milliseconds on each flow that is driven. Two or three hundred flows are opened
first; the appliance is asked what port pf gave each; the slot of each direction and its bucket in
the table of live tuples are computed with the driver's own functions; and the flows that fit the
row are driven for eight seconds while dp.flow_table is read every half second. "Made by" is the
first reading that showed the connection.
A connection that collides with itself, with a held connection in each of its two buckets. Two more flows are opened from ports chosen so that each one's tuple counts in the same bucket as one of the collider's, driven until the table holds them, and kept in use.
| runs | made | |
|---|---|---|
| collides with itself, a held neighbour in each bucket | 7 | 0 |
| collides with itself, no neighbour, in the same runs | 6 | 4 |
In the seven the side entry was armed 11 times and read 0.
The test of a connection that collides with itself again, two colliding flows and a control, one after another: 10 of 12 colliding flows made, where that page has 7 of 7 and 6 of 6; the six controls all made.
The side entry at rest. Three minutes with no test running: armed once, read 0 times, and 1,338 attempts refused for want of a next hop - connections that come in by the management port, whose opening direction no ring will ever carry. At the start of that run the counters since boot read 93 armings, 2 reads, 106 askings while it was another tuple's, 29 let go at the limit; nine minutes of the tests later, 117, 10, 126 and 34.
The bar. A flow that collides with itself and is driven one way only, so that it can never be made, and a second colliding flow driven both ways for three seconds from the fourteenth:
| second | |
|---|---|
| 4 | the entry is armed for the one-way flow |
| 12 | it has held it its eight seconds and is barred |
| 16 | the second flow has been given the entry, read from it and made |
| 18 | the one-way flow is armed again - six seconds after it was barred for thirty |
| 28 | barred again |
| 58 | armed again, thirty seconds after that |
The bar is the tuple still written in the entry, so any other tuple's turn lifts it.
Several at once. Every flow found to collide with itself, all driven together:
| colliding flows | made by, seconds |
|---|---|
| 6 | 0.5, 1.2, 2.6, 3.9, 4.6, 5.9 |
| 2 | 0.5, 1.9 |
| 4 | 1.2, 1.9, 2.5, 3.9 |
| 6 | 1.2, 4.0, 5.3, 6.0, 6.7, 8.0 |
| 3 | 1.2, 2.5, 3.2 |
One a second or so, which is the entry's one place. Across those five runs the entry was wanted 35 times while it was another tuple's.
Several tuples in one slot. Flows from source ports chosen so that their opening tuples share a slot, never used before the drive, all driven together. (A first set of these rows was thrown away: the flows had been used to find them, and were already in the table when the drive began.)
| flows in the slot | made, of them | made by, seconds | candidates overwritten unread |
|---|---|---|---|
| 3 | 3 | 0.5, 1.2, 1.2 | 88 |
| 5 | 5 | 1.9, 2.6, 4.6, 6.0, 8.0 | 890 |
| 8 | 8 | 0.5, 1.9, 2.5, 3.9, 3.9, 5.2, 5.2, 5.9 | 1,684 |
| 12 | 8 | 0.5, 1.9, 3.3, 3.9, 5.3, 5.9, 7.3, 8.0 | 2,476 |
How old the other direction's candidate may be. One direction of a UDP flow is punted once and goes quiet; after a gap the flow is driven both ways.
| gap, seconds | made | "the other direction has not been punted" | a direction attached afterwards |
|---|---|---|---|
| 4 | yes | 0 | - |
| 8 | yes | 0 | - |
| 12 | yes | 0 | - |
| 20 | no | 7 | - |
| 40 | no | 0 (7 attempts put off, pf's state not settled) | - |
| 4, one question after the drive | yes | 0 | 0 |
| 8, the same | no | 6 | 0 |
| 12, the same | yes | 0 | 1 |
| 16, the same | yes | 0 | 1 |
The second set does not agree with the first at eight seconds and was not repeated on this build.
Through the lab tunnel, the next morning on the same boot, ipsec.flows 2: flows whose two
tuples share a slot, found by their ports, and a control, all at once.
| made by, seconds | the control | decrypted frames handed to the host | |
|---|---|---|---|
| six UDP flows | 1.2, 2.6, 3.3, 4.7, 5.3, 6.7 | 0.5 | 963 |
| six UDP flows | 1.2, 1.9, 3.2, 3.9, 5.3, 6.0 | 0.5 | 843 |
| three TCP flows, 203 to 259 Mbit/s each | 0.5, 0.5, 1.9 | 0.5 | 33,839 |
| three TCP flows, 178 to 319 Mbit/s each | 1.2, 1.2, 1.9 | 0.5 | 95,652 |
(The first attempt at each of the two kinds made nothing and echoed nothing: the tool had failed to bind its ports. Not rows.)
Nine hundred connections held, the same morning: nine hundred UDP flows through the tunnel to an echo at the far end, made fifty at a time at fifty datagrams a second each, then each kept in use with a datagram every four seconds.
| the table stopped growing | 110 s after the first flow was opened; 112 s when it was run again from a clean boot |
| connections held | 895 |
| runs between polls that made nothing | 637 |
| creates refused by the coprocessor | 0 |
| one TCP download through the tunnel meanwhile | 794 Mbit/s; 774 from the clean boot |
What these do not say. The tunnel rows and the first of the nine-hundred rows were taken at the end of the boot the ten-hour run above had just finished on; whether a receive ring was behind the block by then was not read. The row from a clean boot left ring 0 seventy-three buffers behind: that is the next section but one.
The same rows on a build that was not merged - withdrawn¶
2026-10-09, module 6896a1cc: main with the candidate table given four places a slot in place of
the side entry, and the reply tuple of an ICMP echo derived as its frames are read
(issue 326). None of this is a
result. At the end of the boot these rows were taken on, receive ring 0 was found 268 buffers
behind the block, and one new connection in eight was timing out. When in the boot it began to
fall behind is not known. The fault is main's and not this build's, but every row below was taken
with it present or possibly present, and each has to be taken again on a build without it. They
are here because they were taken, and so that the next set can be read against them. (Taken
again on 2026-10-11, on f78812a8 - the change over a main that has the fix - from a clean boot
with the rings read level throughout: a slot holds both directions
has every row.)
| row | as it read | on main, above |
|---|---|---|
| collides with itself, a held neighbour in each bucket | 3 of 3 made | 0 of 7 |
| the same runs, no neighbour | 2 of 2 | 4 of 6 |
| the test of the side entry's page | 4 of 4 | 10 of 12 |
| nine colliding flows at once | every one by 1.2 s | one a second |
| six, and seven | every one by 1.2 s; every one by 0.5 s | one a second |
| three, and four, in one slot | all by 0.5 s | 0.5 to 1.2 s for three |
| five in one slot | 0.5 to 1.2 s | 1.9 to 8.0 s |
| eight in one slot | 0.5 to 1.2 s | 0.5 to 5.9 s |
| twelve in one slot | 12 of 12, by 2.6 s | 8 of 12 in 8 s |
| the other direction's candidate 4, 6 and 8 s old | made, nothing attached afterwards | - |
| 9, 10, 12, 14, 20 and 26 s old | made, one direction attached afterwards (two at 9 s); dp.flow_rc_refused 0 |
not made at 20 and 40 |
| 11 s old | no row: the one question after the drive was not answered | - |
| a discard of the flows | 256 places of the candidate table written before it, 0 after | - |
| twelve pings through NAT | 11 answered; dp.flow_tuple_mismatch +0, dp.flow_wait_other +0 |
12 answered; +1 and +1 |
| six UDP flows through the tunnel, twice | every one by 0.5 s; 33 and 123 frames handed to the host | 1.2 to 6.7 s; 963 and 843 |
| three TCP flows through the tunnel, twice | every one by 0.5 s; 3,528 and 3,869 handed up | 0.5 to 1.9 s; 33,839 and 95,652 |
| nine hundred held: the table stopped growing | 33 s | 110 s |
| the same: runs that made nothing | 18 | 637 |
| the same: connections held; the download | 913; 760 Mbit/s | 895; 794 |
And what was seen on that boot that belongs to the fault and not to the change:
| five more sets of fifteen pings through NAT | 14, 15, 15, 15 and 15 answered |
| eight pings to each of six addresses not used before | five answered all eight; one answered none, with dp.flow_wait_other +9 |
| the three-mode tunnel suite | three rows with a stream that timed out: four down and four up with ipsec.flows 0 (one stream of four at 0 in each), one down with ipsec.flows 1 (0.0 Mbit/s); the other nine rows 739 to 964 |
| the suite's second check, three pings of the largest size that fits | 0 of 3 answered |
| forty-two connections to the appliance's own address, source ports in a row | 36 opened, 6 did not; one that did not, five more times: never; one that did: every time |
| the tunnel | found down, no association installed, about an hour into the boot; why was not looked for |
| three hundred of the nine hundred flows, tried again then | none made; nothing echoed |
A ring behind the block, build by build¶
2026-10-09. A packet is not a buffer has the account; this is every row, on every build, including the ones that page gives in a sentence.
On the build it was first seen on, 6896a1cc, an hour into its boot:
| buffers written and not taken | 268, all on ring 0; the same figure in every reading for half an hour |
| ring 0's doorbell, of 2,032 | 1,748 to 1,763; the seven siblings 2,016 to 2,031 |
| what the block had written to each ring since boot | ring 0: 28,263 packets; rings 1 to 7: 1.0 to 4.1 million each |
| interrupts each ring's vector had taken, read a little later | ring 0: 22,253; the others 320,007 to 1,236,048 |
the eight rings' counters together against dp.rx_done |
17,016,890 and 17,008,395 - 8,495 apart, which settled nothing: the driver's total is added to by eight threads without a lock |
| 177 opening frames from one failing port in 8 s | ring 0 took 188 packets in 185 interrupts, and 102 of what it took carried a tag no interface owns; the coprocessor counted 282 frames from the wire and 282 to the host, none dropped |
| sixty-four UDP tuples, a datagram each | 56 seen on the interface's tap within a second, 8 not |
| ring 0 at rest, ten seconds | 20 packets, 13 of them with a tag no interface owns; the one kept carried the control tag, 254 |
| four pings of 1,478, 1,479, 1,500 and 1,550 bytes from the far end | none answered; the coprocessor's FPCNTR_TX_DROP +4 each time; nothing written to a ring |
| four pings of 1,472 bytes | none answered, and no drop counted: not explained, and not tried on another build |
The scale row from a clean boot, on main and on each build of the change:
| build | packets of more than one buffer | buffers they used after their first | the longest, bytes | buffers left written | tuples of 64 seen | the table stopped growing | download |
|---|---|---|---|---|---|---|---|
73d533b9, main |
not counted | - | - | 73 | 56 | 112 s | 774 Mbit/s |
049f58da |
72 | 77 | not kept | 0 | 64 | 112 s | 774 |
7544df3a |
60 | 60 | 2,736 | 0 | 64 | 112 s | 765 |
8e5cc09b, merged |
62 | 62 | 3,114 | 0 | 64 | 114 s | 810 |
b69959d1, between the first two, was not given this row. On it the control frame at rest was
read with the longer capture: type 2, two entries, the same in twelve readings a second apart.
The instrument, dp.oq_lose, on ring 0 - packets acknowledged and not taken, and how they came
back:
| build | 5, the ring at rest | 40, under 300 datagrams back to back | 40, under 300 datagrams 4 ms apart | numbered datagrams | dp.rxwd_stalls |
|---|---|---|---|---|---|
049f58da |
5 in 5 passes | 40 in 10 | 40 in 23 | 600 of 600, in order | 0 |
7544df3a |
5 in 5 | 40 in 7 | 40 in 23 | 600 of 600, in order | 0 |
8e5cc09b |
5 in 5 | 40 in 3 | 40 in 40 | 600 of 600, in order | 1 |
dp.oq_unseen and dp.rx_resync 0 on all three. The first two builds took every packet up to the
first empty buffer; the last takes as many as were there when the wait began. Its walks under the
back-to-back burst were fewer and larger, and one of them brought back enough at once to be
counted a stall. Whether that is the change or the moment was not established.
The three settings of ipsec.flows, twelve seconds a row, Mbit/s:
| build | setting | four down | four up | one down | one up |
|---|---|---|---|---|---|
049f58da |
0 | 966.2 | 957.3 | 789.7 | 771.1 |
| 1 | 964.1 | 954.0 | 797.7 | 769.0 | |
| 2 | 940.2 | 893.7 | 780.5 | 793.9 | |
7544df3a |
0 | 956.6 | 939.3 | 797.9 | 769.3 |
| 1 | 956.1 | 942.1 | 776.2 | 762.0 | |
| 2 | 952.4 | 945.5 | 769.7 | 778.0 | |
8e5cc09b |
0 | 954.9 | 944.2 | 807.6 | 761.6 |
| 1 | 925.7 | 937.6 | 820.4 | 791.8 | |
| 2 | 951.3 | 946.3 | 789.3 | 767.7 |
The six checks and the clear-text probe read on all three as they had before the change. The 893.7 on the first build was not looked into: nothing in that row's counters stands out - no frame refused by the coprocessor, six retransmissions at the far end.
The hand-back rows, downloads with ipsec.flows 2, Mbit/s:
| build | making between polls and reviving | four, 12 s | one, 12 s | four, 30 s |
|---|---|---|---|---|
b69959d1 |
both off | 966.0 | 806.6 | 920.2 |
| making on | 959.8 | 914.7 | 949.6 | |
| both on | 930.3 | 805.2 | 927.9 | |
7544df3a |
both off | 950.3 | 807.9 | 938.5 |
| making on | 954.4 | 792.0 | 934.9 | |
| both on | 957.2 | 769.0 | 938.4 | |
8e5cc09b |
both off | 945.9 | 776.2 | 942.4 |
| making on | 966.5 | 906.5 | 940.6 | |
| both on | 956.3 | 781.5 | 944.7 |
Three rekeys under a four-stream upload, sixteen seconds each, ipsec.flows 0:
| build | Mbit/s | dropped by the far end as replays | dropped while an association installed |
|---|---|---|---|
b69959d1 |
920.6, 935.2, 938.9 | 0, 0, 0 | 0 |
7544df3a |
938.5, 938.7, 928.8 | 0, 0, 1 | 0 |
8e5cc09b |
949.2, 948.3, 944.6 | 0, 0, 0 | 0 |
One frame in nine rekeys, where installed first, taken second counted none in twenty-three. Not looked into: the appliance sent nothing by the kernel's cipher on an association the driver did not yet have, dropped nothing while one installed, and counted no replay of its own.
The receive path's counters over each boot:
| build | what ran | frames | dp.rxwd_stalls |
dp.oq_unseen |
dp.rx_resync, dp.rx_short, dp.oq_lapped, dp.rx_long |
|---|---|---|---|---|---|
049f58da |
scale, instrument, tunnel suite | 11.1 million | 0 | 0 | 0 |
b69959d1 |
hand-back, rekey | 9.6 million | 2 | 2 | 0 |
7544df3a |
scale, instrument, all three suites | 21.3 million | 0 | 0 | 0 |
8e5cc09b |
the same | 22.1 million | 3, one of them the instrument's | 0 | 0 |
No packet was taken without a count on any of them except under the instrument.
The way back, and the stalls, build by build¶
2026-10-11. The way back keeps up has the account. f78812a8 is
main with the fix above; c12b6dfd is main with dp.oq_lose_burst and dp.rxwd_stall_log.
The instrument on ring 0, a numbered UDP stream from the LAN, the interface's tap open for the whole stream:
| build | stream | to lose | lost | back | walks | written and not taken, read every second | handed up |
|---|---|---|---|---|---|---|---|
f78812a8 |
3,000 at 4 ms | 2,100 | 1,489 | 1,489 | 1,489 | never above 1 | 3,000 of 3,000, in order |
f78812a8 |
42 s as fast as the sender makes them, 1,400 a second | 2,100 | 1,463 | 1,463 | 469 | never above 1 | in order |
f78812a8 |
3,000 at 4 ms, nothing lost | 0 | 0 | 0 | 0 | 0 | 3,000 of 3,000, in order |
f78812a8 |
3,000 at 4 ms | 300 | 300 | 300 | 300 | in order |
A first run of the first row read 337 missing: sent after the tap had closed, the tap fourteen seconds and the stream fifteen and a half. One earlier run of the 2,100 row, before the tool counted inversions, printed that the order was not kept; the run after it, the same row, that it was. Not reproduced.
The instrument on the download's ring, under a one-stream download through the tunnel,
ipsec.flows 0, the ring found while the download ran:
| build | download | to lose | a pass may spend | spent | back, in walks | written and not taken at a reading | dp.rxwd_stalls |
|---|---|---|---|---|---|---|---|
c12b6dfd |
780 Mbit/s, 105,000 frames a second, 30 s | 3,000 | 64 | 3,000 within 4 s | 3,000 in 18 | 18 before; 42 at most under it; 0 at the end | +4, the walks of 32 or more |
A second download after it, 775 Mbit/s. dp.oq_unseen and dp.rx_resync 0. A first run of this
row put the instrument on a ring found with a separate download, which hashes to a ring of its
own: the row was void and the tool rewritten.
The stall log, dp.rxwd_stall_log, everything it held on c12b6dfd over the rows above, the
three standing suites run twice, and four rekeys run for it:
| what ran | ring | first pass | ticks nobody inside | the visit |
|---|---|---|---|---|
| the download row, before the instrument was armed | 0 | 50 | 4 | 50 |
| the rekey suite, first run | 7 | 237 | 12 | 603 |
| the rekey suite, second run | 1 | 96 | 5 | 270 |
| under the instrument, the way back's walks | 2 | 195, 347, 300, 252 | 18, 48, 43, 45 | 195, 347, 302, 338 |
| four rekeys, at the first | 2 | 257 | 12 | 624 |
The three-mode tunnel suite and the hand-back suite, twice each: none. The four rekeys, sixteen
seconds each under a four-stream upload with ipsec.flows 0: 932.8, 949.6, 928.3, 907.8 Mbit/s;
dropped by the far end as replays 0, 0, 0, 0; packets the kernel numbered before the coprocessor
took the association 697, 945, 33, 943. A sampler on the appliance every 100 ms across the fourth
rekey, 406 readings: written and not taken on a ring 1 to 29, the 29 on ring 4 in the upload's
first second, single digits after. The samplers of the first three rekeys were overwritten, each
by the next, and the log's readouts between rekeys printed an error in place of the log - the tool
sent $(...) to the appliance's csh; the log itself keeps the last eight and was read after.
Six more rekeys with the tool fixed, 2026-10-12, every sampler kept, same upload and setting:
| rekey | Mbit/s | replay drops at the far end | install, microseconds | stall | the sampler's most written and not taken on a ring, at one reading |
|---|---|---|---|---|---|
| 1 | 931.5 | 1 | 5,008 | none | 15 on ring 6 |
| 2 | 953.3 | 0 | 81 | none | 203 on ring 2, a servicer inside it |
| 3 | 948.4 | 0 | 95 | none | 35 on ring 1 |
| 4 | 953.7 | 0 | 1,176 | ring 1, first pass 109, 6 ticks nobody inside, visit 320 | 95 on ring 1, at the install's reading |
| 5 | 950.4 | 0 | 91 | none | 12 on ring 2 |
| 6 | 948.6 | 0 | 42 | none | 28 on ring 6 |
Four stalls at a rekey in twelve rekeys now, and the one replay drop of the ring-fault builds (1 in 9) is 1 in 10 since. The install's hold of the driver's lock, which the termination of every decrypted frame also takes, was checked against these durations: a 1.2 ms install beside a stall of six ticks, a 5 ms install beside none. Not it alone.
The NUL bytes in the sampler's log, and where they came from. The sampler's log of the
fourth rekey, 36,870 bytes in 406 lines, carried 1,896 NUL bytes, and the first reading of that was
that dp.state's output carries them. It does not: read three times at rest on 2026-10-12,
sysctl -n dev.octep.0.dp.state gave 1,115 bytes each time and no NUL in any. The NULs are one
contiguous run at offset 34,812 of the log, with the fourth rekey's last lines before it and the
third rekey's last lines after it: the tool ran every rekey's sampler into the same file, the
fourth truncated it while the third was still writing at its old offset, and the bytes between
the two writers' positions are the hole a file system leaves. The tool writes a file a rekey now.
The sampler's readings themselves - written and not taken on a ring, 1 to 29 - are unaffected,
the hole being after the last of them.
An address change under a mirrored association¶
2026-10-12, module c12b6dfd, for issue 317:
the first time one end of the tunnel changed its address under an association the coprocessor
was encrypting. Installed first, taken second
has the account.
How. The lab tunnel on the 10G path, the appliance made the IKE initiator (only the initiator
moves its address, RFC 4555), dp.auto 1 and ipsec.flows 0, so every frame crosses the host and
the coprocessor is the only cipher. A second address, in a second /30, on the tunnel's port, with
the connection's local addresses set to both; on the peer a second runtime address in that /30,
so the moved address is on-link. A stream of 200 datagrams a second from the LAN machine
through the tunnel for 40 s, the peer capturing the ESP from either address and the IKE; eight
seconds in, the first address taken away from the port. Then, in a second run, put back.
| the address taken away | the address put back | |
|---|---|---|
| what charon did | MOBIKE moved the IKE SA to the second address pair, then unable to update SAD entry with SPI c2184f81: reqid change is not supported, and rekeyed the child: new SPIs, a fresh exchange |
nothing: it stays on a path that works |
the kernel's clone, key_updateaddresses |
not called | not called |
| the driver | sa_installed 36 to 38, sa_removed 34 to 36, sa_failed 0, out_drop 0, tx_nosa 0, rx_nosa 0, rx_bad 0 |
unchanged |
| the old association's last packet, the new one's first | 00:19:47.408 and 00:19:47.528: 120 ms | - |
| ESP captured at the peer | 1,621 on the old association, 6,145 on the new; sequence numbers consecutive on each; IV equal to the sequence number on all but the capture's cut last line | 7,772 on the one association, consecutive, IV equal to the sequence number |
| the kernel's cipher | sent nothing: no lower-half IV, no upper-half IV | the same |
| dropped by the peer as a replay | 0 | 0 |
| the stream | 8,000 sent; the capture opened about a second after it began | 8,000 sent |
Two things the run itself did that are worth knowing. The host route to the peer's first address
by the tunnel's port was not installed by the tool (route add -host ... -iface is not the
flag), so for the seconds between the address going and MOBIKE settling, the appliance's route to
that address was its default, and charon's path probes to it left by the WAN - three IKE probe
packets to a lab address, out of the upstream port, before the probe of the second pair answered.
And the capture's last line is cut by the capture's own timeout, which is the one packet on each
run whose IV could not be read.
The first connection through the tunnel, and where the MSS clamp has to sit¶
2026-10-12, module c12b6dfd, for issue 321:
the first TCP connection a LAN machine opens through the tunnel stands still for 0.3 s while it
learns the path's MTU - ten 1,460-byte segments with DF, ten "fragmentation needed" answers from
the appliance, a 300 ms retransmission timer. The issue said what would remove it, a maximum MSS
on the tunnel's traffic, and did not try it. Tried, in three forms, on the lab tunnel over the 10G
path, one connection from the LAN machine each time with the far end capturing the decrypted side
and the appliance's ipsec.out_needfrag read before and after:
| setting on the appliance | the SYN's MSS as the far end received it | ipsec.out_needfrag |
the connection |
|---|---|---|---|
| none | - | +11 | the machine learned a path MTU of 1,446 |
a normalisation rule, scrub in on <the member port> proto tcp to <the far end> max-mss 1406 |
1460 | +0 | 1 MB in 10 ms - the machine's own memory of the path, not the rule: the rule matched no packet at all (evaluated 102,713 times, 0 packets), because OPNsense's scrub in all comes after it and the last matching normalisation rule wins |
the interface's MSS field on the member port the machine is cabled to: scrub on oxp5 inet all max-mss 1406 |
1460 | +0 | the same; the rule matched 38 packets and clamped nothing: the machine's packets reach pf on the bridge, not on the member |
the interface's MSS field on the bridge, the LAN: scrub on bridge0 inet all max-mss 1406 |
1406 | +0 | 1 MB in 10 to 30 ms, no pause over 100 ms, no segment over 1,406 |
So the remedy is the MSS field of the interface pf sees the LAN machine on - the bridge here - set to the tunnel's MTU plus forty, 1,446. Nothing in the driver.
Three things the measuring found about itself. The Windows machine's memory of a path's MTU
outlives what netsh interface ipv4 show destinationcache lists, and the peer's keep-alive pings
to it refreshed that memory, so two "after" runs read 1,406-byte segments from a machine that had
not been clamped; the SYN's MSS at the far end is the reading that cannot be fooled that way, and
is the column above. The first run's client sent no mode byte to the far end's server and was
reset before it could time anything, so the 0.3 s itself is the issue's own reading of 2026-10-08
and not this day's. And out_needfrag rose by ten between two runs while nothing of the tool's
was running and the pinger was stopped: a first connection from the machine that was not this
tool's; which, not known.
An association behind NAT, on the coprocessor¶
2026-10-12, for issue 294's first item;
the UDP header is the host's to write has the account. The lab
tunnel over the 10G path with encapsulation forced at both ends (ESP inside UDP 4500), dp.auto 1,
ipsec.flows 0.
| build | the association | inbound, the peer's frames | outbound, the appliance's frames | rates, Mbit/s |
|---|---|---|---|---|
b2564f90: taken, ports and udp_enable in the request, envelope plain ESP |
mirrored: esp-udp in the kernel, two table rows |
decrypted and terminated, rx_done +106, rx_bad 0 |
refused by the coprocessor: FROM_KN_TO_IPSEC_ENCR +5, CRYPTO_DROP_PROTO_ERR +5, nothing on the wire |
0 - every stream timed out |
1707f625: the envelope carries the UDP header |
the same | rx_done 4,538,392, rx_bad 0, rx_nosa 0, rx_nokey 0; FROM_WIRE_TO_IPSEC_DECR +285,858 over a burst |
accepted: the peer's ESP in +2,526,537, replay drops unchanged, its capture shows the appliance's ESP inside UDP from sequence 0x100001; FROM_KN_TO_IPSEC_ENCR +285,787, no CRYPTO_DROP_*; out_drop 0, tx_nosa 0 |
four streams 929.2 up, 957.9 down; one stream 798.5 up, 826.4 down |
A 1,472-byte ping with DF is answered "needs to be fragmented"; 1,410 bytes, eight less than the
plain tunnel's largest, gets three of three replies. Lab raised 01:24, down 01:36; the peer's
encap turned on and off again, its configuration otherwise untouched, zero leaves differing after.
An association with extended sequence numbers¶
2026-10-12, for issue 294's second
item; under ESN, what arrives stays with the kernel
has the account. The lab tunnel over the 10G path with esp_proposals = aes128gcm16-esn at both
ends (the pair shows ESP:AES_GCM_16-128/ESN, the kernel flags=0x400), dp.auto 1,
ipsec.flows 0. The sequence is four streams up 16 s, four down 16 s, one up and one down 12 s,
pings, then a rekey under a four-stream upload with the peer capturing the numbers.
| build | the association | inbound, the peer's frames | outbound, the appliance's frames | four streams up / down, one stream up / down, Mbit/s |
|---|---|---|---|---|
c92ca5dd: both directions taken, ext_seq in the request |
mirrored, two table rows | decrypted and terminated for 1.36 to 3 million frames, then every frame CRYPTO_DROP_AUTH until a rekey - three runs in four, each after the four-stream upload; across one dead download FROM_WIRE_TO_IPSEC_DECR +13,550, CRYPTO_DROP_AUTH +1,379, TX_DROP +1,379; rx_bad 0, rx_nosa 0, rx_nokey 0 |
accepted throughout: the peer's replay drops 0, its capture clean at the rekey | 935.5 / 21.3 with four timeouts / 0 / 0; the second run 952.9 / 13.3 / 0 / 0; the run with a 5 s pause before the download 953.8 / 824.2 (one stream) |
a4c1091e: the arriving association refused, the leaving one taken |
one table row, encrypt; the kernel holds both; 23 refusals per pair in sa_refused (every association is offered to all twelve interfaces) |
decrypted by the kernel's cipher: its ESP input 151 to 5,861,941, replay 0; rx_done unchanged at 13; no FROM_WIRE_TO_IPSEC_DECR, no CRYPTO_DROP_* |
tx_encrypt 3,876,458 = FROM_KN_TO_IPSEC_ENCR +3,876,458; the peer's ESP input +3,876,460, replay drops unchanged; out_drop 0, tx_nosa 0 |
933.9 / 955.6 / 796.4 / 822.8; pings 3/3 after; the rekey under load 940.7, replay 0, the kernel's cipher numbered 1 and 2 and the coprocessor's begin at 0x100002 |
The host over the second build's run, the kernel's cipher taking a gigabit arriving: irq88: octep0
45.9%, swi1: netisr 0 30.0%, four more octep0 interrupt threads at 16.7 to 17.4%. The
coprocessor over that run: TX_DROP_QUEUE_FULL +126 (the first build's runs saw +1),
FROM_WIRE_TO_KN_MFLOW_NOT_ACTIVE +9,739,497. A 1,472-byte DF ping is answered "needs to be
fragmented", 1,418 bytes gets three of three. Lab raised 02:30, down 02:34 for the second build
(02:13 to 02:27 for the first); the peer's generated swanctl.conf edited and regenerated from its
template after, its configuration otherwise untouched, zero leaves differing after each.
Replay windows other than 32¶
2026-10-12, for issue 294's last
item; a window wider than 256 has the account. The lab
tunnel over the 10G path with replay_window = N in both children, dp.auto 1, ipsec.flows 0;
each row is four streams up 16 s, four down 16 s, one each way 12 s, five pings, the coprocessor's
counters read across all of it. Module a4c1091e.
| N | the association | inbound, the peer's frames | outbound, the appliance's frames | up x4 / down x4 / up x1 / down x1, Mbit/s |
|---|---|---|---|---|
| 64 | mirrored, win 64; kernel replay=8 |
rx_done +4,500,152, rx_bad 0; FROM_WIRE_TO_IPSEC_DECR +4,500,902, CRYPTO_DROP_REPLAY_OOW +19, TX_DROP_QUEUE_FULL +398 |
tx_encrypt +2,519,961 = FROM_KN_TO_IPSEC_ENCR; the peer's ESP input +2,519,961, its replay drops unchanged |
933.4 / 960.8 / 788.4 / 802.3 |
| 128 | win 128; replay=16 |
+4,511,552, 0; +4,512,334, REPLAY_OOW +14, QUEUE_FULL +155 |
+2,550,740, the peer the same | 949.4 / 961.1 / 790.1 / 791.8 |
| 256 | win 256; replay=32 |
+4,535,721, 0; +4,536,328, REPLAY_OOW +11, QUEUE_FULL +147 |
+2,585,429, the same | 944.5 / 933.5 / 828.5 / 819.6 |
| 1,024 | win 1024; replay=128 |
+4,542,081, 0; +4,542,939, no REPLAY_OOW, QUEUE_FULL +209 |
+2,568,113, the same | 954.7 / 959.7 / 795.2 / 808.8 |
| 4,096 | win 4096; replay=512 |
+4,500,636, 0; +4,501,227, no REPLAY_OOW, QUEUE_FULL +128 |
+2,554,122, the same | 947.4 / 957.0 / 788.9 / 791.4 |
| 48 | refused (not a power of two): one row; kernel replay=6 |
the kernel's cipher: its ESP input +4,534,348, its replay drops 0 to 32; rx_done unchanged; no FROM_WIRE_TO_IPSEC_DECR |
+2,564,493, the same | 952.6 / 956.2 / 802.7 / 801.9; host irq84: octep0 45.2 %, netisr 30.4 % |
8,192, module ca92801b (the cap at 4,096) |
refused: one row; kernel replay=1024 |
the kernel's cipher: +4,532,578, its replay drops 0; rx_done unchanged; no FROM_WIRE_TO_IPSEC_DECR |
+2,558,312, the same | 934.4 / 956.3 / 803.6 / 822.4; host irq88: octep0 43.4 %, netisr 27.5 % |
4,096, module ca92801b |
win 4096; replay=512 |
+4,474,873, 0; +4,475,369, no REPLAY_OOW, QUEUE_FULL +265 |
+2,534,903, the same | 955.0 / 961.0 / 780.7 / 761.1 |
Every ping 5/5. The host with the coprocessor's cipher, every row: netisr 30 to 32 %, four
octep0 interrupt threads at 25 to 26 %. Lab raised 05:36, the second module installed by reboot
at 05:46 with the lab up (the pinger stopped first), down 05:50; the peer's generated
swanctl.conf edited and regenerated from its template after, zero leaves differing.
AES-CBC with an HMAC on the coprocessor¶
2026-10-12, for issue 294's block-cipher
item; AES-CBC with an HMAC runs at two thirds of GCM's rate
has the account. Module b282ce62, the lab tunnel over the 10G path, the proposal hand-set at both
ends, dp.auto 1, ipsec.flows 0; each row four streams up 16 s, four down 16 s, one each way 12 s,
five pings, the coprocessor's counters read across it.
| proposal | the association | inbound, the peer's frames | outbound, the appliance's frames | up x4 / down x4 / up x1 / down x1, Mbit/s |
|---|---|---|---|---|
aes128-sha256 (IV 16, ICV 16) |
mirrored, two rows; kernel aes-cbc + hmac-sha2-256 |
rx_done +3,140,345, rx_bad 0; FROM_WIRE_TO_IPSEC_DECR +3,140,965, CRYPTO_DROP_REPLAY_OOW +45 |
tx_encrypt +1,965,655 = FROM_KN_TO_IPSEC_ENCR; the peer's ESP input +1,965,655, its replay and authentication drops unchanged |
669.9 / 609.4 / 602.4 / 561.1; 1,402-byte ping 3/3 |
aes256-sha1 (IV 16, ICV 12) |
the same; hmac-sha1 |
+3,173,430, 0; +3,174,098, REPLAY_OOW +44 |
+1,997,046 = ENCR; the peer's input +1,995,655 (1,391 short; its authentication drops 0, its "queue full" 3,131 cumulative, unread before) |
688.4 / 612.1 / 608.9 / 556.9; 1,402 3/3 |
aes256-sha512 (IV 16, ICV 32) |
the same; hmac-sha2-512 |
+3,286,180, 0; +3,287,001, REPLAY_OOW +40 |
+2,097,933 = ENCR; the peer's input +2,097,933 |
714.2 / 632.5 / 633.0 / 556.9; 1,402 refused (DF), the largest inner 1,422 |
aes128gcm16 the same hour, for comparison |
947.4 / 957.0 / 788.9 / 791.4 |
Every ping 5/5; a 1,472-byte DF ping "needs to be fragmented" on all. The same day, module ca92801b
and b282ce62, the two items the driver does not decide
(GCM-12 is the kernel's no, transport mode the vendor's):
| item | what happens |
|---|---|
esp_proposals = aes128gcm12 |
the appliance selects AES_GCM_12_128, its kernel refuses both SAs (Invalid argument (22)), NO_PROPOSAL_CHOSEN to the peer, no CHILD_SA; sa_refused unchanged |
mode = transport, the endpoints as selectors |
INSTALLED, TRANSPORT at both ends; no table row, sa_refused +48; pings 5/5 at 0.27 ms; one stream sourced at the appliance 1,526.7 down / 1,303.6 up through the kernel's cipher, its ESP in +2,096,721 and out +2,095,716; rx_done and tx_encrypt unchanged |
Lab raised 06:12, the module installed by reboot before it, down 06:17; the earlier GCM-12 cycle
06:03 to 06:04; the peer's generated swanctl.conf edited and regenerated from its template after,
zero leaves differing after each.
An IPv6 outer header¶
2026-10-12, for issue 294's IPv6 item,
the outer half; the outer header can be IPv6 has
the account. Module c644d91b; the lab tunnel's endpoints on a ULA /64 laid on the 10G path at
both ends, the selectors IPv4, dp.auto 1, ipsec.flows 0; each row four streams up 16 s, four
down 16 s, one each way 12 s, five pings, the coprocessor's counters read across it.
| proposal | the association | inbound, the peer's frames | outbound, the appliance's frames | up x4 / down x4 / up x1 / down x1, Mbit/s |
|---|---|---|---|---|
aes128gcm16 |
mirrored, the table's two rows with the v6 addresses | rx_done +4,502,986, rx_bad 0; FROM_WIRE_TO_IPSEC_DECR +4,503,704, CRYPTO_DROP_REPLAY_OOW +47, TX_DROP_QUEUE_FULL +145 |
tx_encrypt +2,551,889 = FROM_KN_TO_IPSEC_ENCR = the peer's ESP input; out_nonhop 0 |
935.9 / 941.1 / 786.4 / 773.2; 1,398-byte ping 3/3 |
aes128-sha256 |
the same | +3,102,597, 0; +3,103,086, REPLAY_OOW +41 |
+1,989,485, the peer the same | 674.6 / 589.4 / 594.0 / 502.8 |
aes256-sha1 |
the same | +3,156,423, 0; +3,156,998, REPLAY_OOW +45 |
+2,024,805; the peer's input +2,023,733, its authentication drops 0 | 691.7 / 589.6 / 604.7 / 517.5 |
Five of the appliance's CBC frames at the peer: sequence 0x100001 to 0x100005, a different
sixteen-byte IV in each. Lab raised 06:30, the module installed by reboot under it twice (06:44
instrumented, 06:54 fixed; the peer's pinger stopped first), down 07:01; the peer's alias and
generated swanctl.conf back as they were, zero leaves differing; the three void attempts before
the fix are in the finding.
IPv6 inside the tunnel¶
2026-10-12, for issue 294's IPv6 item,
the inner half; IPv6 inside the tunnel, delivered has
the account. Module f9f5ba7e; the lab tunnel over its IPv4 10G path with IPv6 selectors (a ULA
/64 on the appliance's LAN bridge, the peer's ULA /128), AES-GCM-16, dp.auto 1, ipsec.flows 0;
the traffic sourced at the appliance itself over the 10G path.
| reading | |
|---|---|
| pings through the tunnel | 5/5, 0.34 ms |
| one stream up / down 10 s; four streams up / down 12 s | 1,820.8 / 1,325.5; 2,136.0 / 1,699.7 Mbit/s |
| inbound | rx_v6 +7,395,177 delivered to IPv6 input, rx_bad 0, rx_blocked 0; FROM_WIRE_TO_IPSEC_DECR +7,395,602; rx_queuefail ~210,000 (the IPv6 netisr queue at 1.7 Gbit/s into the host's own stack) |
| outbound | tx_encrypt +7,397,169 = out_taken = FROM_KN_TO_IPSEC_ENCR = the peer's ESP input; its authentication drops 0; out_orig 0 |
| the largest packet | 1,398-byte ping passes (inner 1,446), 1,402 refused, not fragmented |
| the coprocessor; the host | REPLAY_OOW +94, QUEUE_FULL +3; netisr 68.6 %, the load generator 63.8 %, irq85: octep0 54.9 % |
The forwarded path the same afternoon (14:40, the machine behind the appliance with a temporary
address in the LAN /64): pings 5/5; one stream 760.0 / 802.7, four streams 935.3 / 946.6 Mbit/s;
rx_v6 +3,613,105 with rx_bad 0, tx_encrypt +2,084,625 = the peer's ESP input; a 1,398-byte
ping lost from the machine where it passes from the appliance. Bisected at 15:00 and 15:18:
1,335 bytes of ping data pass and 1,336 do not - an inner packet of 1,383, which is 1,500 less
the kernel's worst-case IPsec header estimate of 117 (ipsec6_check_pmtu with
ipsec_hdrsiz_internal: 8 + 64 + 9 + 16 + 20) on every forwarded IPv6 packet; purging the host
cache changes nothing (the check writes the route's MTU back into it), the IPv4 twin is off by
default (net.inet.ipsec.dfbit 0), and the appliance's own packets skip it. The driver's
counters do not move for a refused one; the kernel counts a Packet Too Big per attempt.
Four raises (07:15 to 07:25); the three void ones are the kernel's route-before-policy and the
port's ifdisabled, in the finding. The peer's temporary pass in quick on enc0 inet6 rule
(its owner's word) was a runtime addition and is gone (0 after the filter reload), its route and
alias removed, zero leaves differing.
Two hours of TLS through the tunnel, with the load at the lab's far end¶
2026-10-12, for issue 279, module
f9f5ba7e. The lab peer serves a 1 GiB file of random bytes over HTTPS from its lab alias on the
10G path; the PC downloads it through the appliance and the IPsec tunnel - the coprocessor's
AES-GCM both ways, dp.auto 1 - in three concurrent loops for 120 minutes. Two oracles per pass:
curl's TLS stack and the file's SHA-256 against the server's.
| passes | 651, every one rc=0, HTTP 200, 1,073,741,824 bytes, the server's hash |
| TLS errors, different hashes, other exits | 0, 0, 0 |
| per-stream rate | 90 to 659 Mbit/s, 365 on average; about 780 Mbit/s aggregate over the two hours |
| the driver over the run | rx_bad 0, out_drop 0, tx_nosa 0; rx_done to 546.8 million |
| the coprocessor | RX_IPSEC +521.4 million, TX_DROP_QUEUE_FULL +23,491 (its queue to the host under three downloads; TCP covered it), FROM_WIRE_TO_KN_MFLOW_NOT_ACTIVE +601 million; flows made 156 |
The first run of the same tool was void from its second pass: the loop clobbered its stream id,
three loops wrote one file, and one pass hashed DIFFERENT with a clean TLS exit - the tool's own
race, caught in two minutes; the numbers are the restarted run's. Lab raised 12:33, down 14:40
(the longest raise of the lab so far); the server, the file and the certificate lived in the
peer's /root/lab, removed with it, zero leaves differing after.
The MTU, from 72 to 9,000, on the spot¶
2026-10-12, modules dac136a1 and d93f152c; the MTU is the LIF's
has the account. The 10G port's MTU set with ifconfig, which now tells the port's LIF; the peer's
bridge at 9,000 on the 10G path; the peer's DF pings and the appliance's own TCP.
| reading | |
|---|---|
| what stopped a 1,522-byte frame before | the fast path, at the LIF's MTU of 1,500: FROM_WIRE_DROP_MTU_EXCEEDED +1 per frame, the 10G MAC having passed a 9,014-byte one |
| the LIF told 9,000 through the instruments | the drops stop; a 9,014-byte frame arrives in six buffers: rx_spans 12, rx_span_bufs 24, rx_span_max 9,088 after twelve pings |
ifconfig oxp0 mtu 9000 on d93f152c, DF pings of 1,472 to 8,972 data bytes |
all answered; 8,973 (a 9,015-byte frame) refused; rx_spans 2,029,355 over the run, rx_span_nobuf 0 |
| TCP from the appliance, one stream 8 s; four streams 10 s | 7,386.6 / 7,178.4; 8,703.6 / 8,876.7 Mbit/s |
| the port back at 1,500 | 1,853.3 / 2,323.5 one stream; a 1,522-byte frame refused again |
| through the tunnel at 9,000: the largest DF ping; TCP one stream / four; the driver | 8,918 answered, 8,922 refused (inner 8,946 = 9,000 less 54); 2,007.9 / 1,502.8 and 2,366.4 / 1,856.8 (one up stream timed out); rx_bad 0, tx_encrypt = ENCR +6,605,231, rx_spans +2 - the peer's MSS clamp of 1,406 on its bridge keeps the segments small |
the first build, dac136a1 |
the slot's physical address on the old stride: every transmit read from the wrong slot, no port passed a frame, the appliance's WAN gone for three minutes until d93f152c |
The lab's alias on the peer added and removed around each run, the tunnel raised 16:02 and down 16:04, zero leaves differing.
The clear-text path at a gigabit, through the host and with the flow table¶
2026-10-12, 17:15 to 17:20, module d93f152c, for issue 320
(plain forwarding through the host had never been run: every gigabit row before this was through
the tunnel) and issue 315 (a plain
connection given back by the fast path, and whether it is revived). A far end OUTSIDE the tunnel's
selectors: a second /30 on the lab wire, the peer's alias on its bridge, the appliance's on the 10G
port, the peer routing the LAN behind the appliance back by it, and a TCP load generator's server
on the peer's alias. The LAN machine's traffic to it is routed through the host in the clear - the
peer's capture shows the echo request and reply as plain ICMP on its bridge - and NAT is not in it.
The first attempt of the day (01:47) had stopped at the peer's default block: its LAN pass rule
names its own network only. A temporary pass rule on the peer for the one source network to the
one address, runtime only and dropped by a filter reload at the end, was the whole difference.
Four streams for 16 s and one stream for 12 s, each way, from the LAN machine (gigabit port behind the switch) to the peer (10G port); the driver's receive counters and the coprocessor's across each regime:
dp.auto |
four up / four down | one up / one down | the driver | the coprocessor |
|---|---|---|---|---|
| 0, through the host | 957.1 / 964.0 Mbit/s (238-241 and 183-312 a stream) | 783.8 / 810.7 | rx_done +6,746,841; oq_behind, oq_unseen, rx_resync, rx_short, oq_lapped, rxwd_stalls, credit_capped all 0 |
RX_WIRE +6,747,465, TX_WIRE +6,747,213, MFLOW_NOT_ACTIVE +6,747,196 (every frame to the host), TX_DROP_QUEUE_FULL +48 |
| 1, the flow table | 955.1 / 964.8 (238-240 and 195-268) | 771.8 / 789.4 | rx_done +28,703; flow_revived +42, revive_refused 0, flow_wait_other 0 |
FROM_WIRE_TO_WIRE +6,630,528 of RX_WIRE +6,657,897 (99.6 %), TCP_MAX_RETRANS +43, CONN_RECLAIM_PENDING +25,181, TX_DROP_QUEUE_FULL +75 |
So the host forwards a gigabit of plain TCP by itself, four streams at the line's rate and one stream at 78 to 81 % of it, with the receive path's fault counters at zero - the row #319's rows through the tunnel had only implied. The flow table adds nothing to the rate here (the line is the LAN machine's gigabit port) and takes the host out of 99.6 % of the frames.
A plain connection given back, and revived (#315's second item). One stream for 30 s each way with the trigger on, the counters across each:
| rate | the fast path's rule | the driver | frames handed up while it waited | in hardware | |
|---|---|---|---|---|---|
| download (the peer sends, 10G into the gigabit port) | 795.6 Mbit/s | TCP_MAX_RETRANS +17 |
flow_revived +17, revive_refused 0 |
CONN_RECLAIM_PENDING +17,920 - about 1,050 a give-back, 16 ms at this rate |
2,291,280 of 2,310,063, 99.2 % |
| upload (the gigabit port sends) | 753.8 | 0 | 0, 0 | +3 | 3,872,719 of 3,873,314, 99.98 % |
The rule fires on the download only, where the 10G side pours into the gigabit port and the
coprocessor's queue to that port drops a frame now and then (TX_DROP_QUEUE_FULL +4 in the 30 s);
the retransmissions that follow are what the rule counts. Every one of the seventeen was revived
in place, none refused, and the connection was back in hardware about a thousand frames later each
time. The 42 of the four-stream run above are the same thing, 43 fired and 42 revived - the one
not revived was a connection at its end. On the upload nothing retransmits and nothing is given
back. That is the plain connection's behaviour, and it is the tunnelled one's.
Thirty downloads across the WAN, the real round trip (#315's first item). The lab has no delay element at its far end and a pipe on the appliance would delay the host path only, so the one path with a real round trip is the appliance's WAN: the cycle of the ten-hour run (a download from a public speed-test server, one at a time, cut at six seconds, the trigger on), thirty of them back to back, 17:22 to 17:25, the counters read after each.
| downloads | 30, 6.5 to 68.4 MB in the six seconds, 9 to 95 Mbit/s, three servers in turn |
TCP_MAX_RETRANS, flow_revived, revive_refused |
0, 0, 0 - no download was given back for the rule |
CONN_RECLAIM_PENDING |
on 15 of the 30, +4 to +564 frames, 3,553 in all, each with TCP_FIN_SYN_RST +1 at the cut: the connection handed back on curl's close, and the server's segments still in flight arriving while it waited to be reclaimed |
| the ten-hour run of 2026-10-08, same cycle | 1 of 60 given back |
At 40 ms and under 100 Mbit/s the rule does not fire at all now; the issue's "one in ten" was two builds ago. What a long burst of duplicate acknowledgements does to the wait that doubles stays unmeasured, because no path here makes one: it needs tens of milliseconds AND a rate that retransmits, and the lab's far end is the owner's production firewall, which takes no pipe.
The peer: alias, route, server and the temporary rule in place 17:15 to the end of the TLS soak that followed on the same path; the rule dropped by a filter reload, zero leaves differing after.
Three hours of TLS on the clear-text path, with the trigger on and then off¶
2026-10-12, 17:27 to 20:28, for issue 279,
module d93f152c: the soak of the two hours through the tunnel above, run on the clear-text path
of the section before this one - plain forwarding through the host, which is the path of the two
failures of 2026-10-06 - for 90 minutes with the trigger on and 90 with it off. Three concurrent
download loops of the 1 GiB random file over HTTPS from the peer's second alias; curl's TLS stack
and the file's SHA-256 the oracles, every pass recorded.
| trigger on, 17:27 to 18:57 | trigger off, 18:57 to 20:28 | |
|---|---|---|
| passes | 524, every one rc=0, HTTP 200, 1,073,741,824 bytes, the server's hash |
524, the same |
| TLS errors, different hashes, other exits | 0, 0, 0 | 0, 0, 0 |
| per-stream rate | 105 to 697 Mbit/s, 397 on average; about 830 aggregate (the LAN machine's gigabit port) | 92 to 765, 402; about 830 |
| the coprocessor | FROM_WIRE_TO_WIRE +439,136,341; TCP_MAX_RETRANS +3,741; CONN_RECLAIM_PENDING +843,479; MFLOW_NOT_ACTIVE +297,721; TX_DROP_QUEUE_FULL +19,917; 2,626 flows made |
MFLOW_NOT_ACTIVE +442,890,333 - every frame to the host; TX_DROP_QUEUE_FULL +20,504 |
| the driver | flow_revived +3,740, revive_refused 0 |
rx_done +439.1 million, intr_taken +75.7 million; the three busiest ring handlers at 21, 20 and 11 % of a core |
| the receive path's fault counters | oq_lapped, rxwd_stalls, oq_behind, oq_unseen, rx_resync, credit_capped all 0 |
all 0 |
1,048 GiB in the clear through the appliance at the LAN machine's line rate, half of it through
the host frame by frame, and nothing changed a byte. With the 651 passes through the tunnel that
morning, 1,699 passes and no failure on this build. The two failures of 2026-10-06 were at 200 to
300 Mbit/s on the WAN's NAT path, through the host; the second half here covers that path and that
rate, NAT aside, three streams at once, and does not reproduce them. The issue's criterion is a
month; this is three hours of it. The month was a number set on the day of the failures and
derived from nothing; it was withdrawn the evening of 2026-10-12 on the owner's word for the
criterion the failures themselves set - their own path and rate, translated through the host at
200 to 300 Mbit/s a stream, the trigger on and then off, clean for two hours - and that run is the
next section.
Two hours of TLS on the failures' own path: translated, through the host¶
2026-10-12, 21:17 to 23:18, module d93f152c, issue 279's
closing run under the criterion above. The path of the two failures of 2026-10-06, rebuilt on the
lab wire: the appliance translates the LAN behind it to its own address on the 10G port (a runtime
nat on oxp0 rule in the translation section of the loaded ruleset, dropped by a filter reload
after), the peer sees one host and its temporary rule passes that one host to the one alias - the
peer's capture shows the echo request from the translated address, the appliance's state table
shows the translation, the flow table's connections carry do_snat. The same three download
loops of the 1 GiB random file over HTTPS, the same two oracles, an hour with the trigger on and an
hour with it off.
| trigger on, 21:17 to 22:17 | trigger off, 22:17 to 23:18 | |
|---|---|---|
| passes | 349, every one rc=0, HTTP 200, 1,073,741,824 bytes, the server's hash |
343, the same |
| TLS errors, different hashes, other exits | 0, 0, 0 | 0, 0, 0 |
| per-stream rate | 95 to 742 Mbit/s, 398 on average; about 830 aggregate | 82 to 722, 396; about 810 |
| the coprocessor | FROM_WIRE_TO_WIRE +292,202,734; TCP_MAX_RETRANS +2,368; CONN_RECLAIM_PENDING +479,228; MFLOW_NOT_ACTIVE +130,967; TX_DROP_QUEUE_FULL +13,028 |
MFLOW_NOT_ACTIVE +289,592,336 - every frame to the host; TX_DROP_QUEUE_FULL +13,598 |
| the driver | flow_revived +2,368 = the give-backs, revive_refused 0 |
rx_done +289.6 million |
| the receive path's fault counters | oq_lapped, oq_behind, oq_unseen, rx_resync, credit_capped 0; rxwd_stalls 3 - below |
all 0, rxwd_stalls +0 |
692 GiB through the failures' own path at the LAN machine's line rate, 200 to 740 Mbit/s a stream, translated by the host for an hour and translated and accelerated for another, and nothing changed a byte. With the day's other runs, 2,391 passes and no failure on this build. The criterion is met and the issue is closed on it; what would reopen it is the thing itself, recurring, with the capture its body asks for.
Three rings left with a burst, all with the trigger on (issue 361).
rxwd_stalls, zero through every run of the day until then, counted three in the first hour:
| when | ring | first pass | idle before | the visit took |
|---|---|---|---|---|
| 21:33:37 | 7 | 53 packets | 234 ticks | 53 |
| 21:57:01 | 5 | 32 | 927 | 47 |
| 21:59:50 | 0 | 92 | 67 | 99 |
Each is a burst that landed on a ring nobody was inside - idle for 67 to 927 ticks - and raised no
handler until the watchdog's visit, at most one period later. None at a rekey (the association was
seventeen minutes old at the first and its rekey forty minutes away); none in the hour with every
frame through the host, where no ring is ever idle; none in the three hours on the clear-text path.
So it is not the load but an idle ring that a burst finds, and the trigger on is what leaves rings
idle. TCP covered the delay every time - the passes around each were clean - and oq_bound did not
move. It is the shape the fourth section of #320 left as a reading, and it is now an issue with a
log of its own.
The peer: alias, rule, server in place 21:16 to 23:19; both temporary rules dropped by a filter
reload at each end (0 left at both), the aliases gone, /root/lab gone, zero leaves differing, its
WAN-path tunnel re-initiated.
A ring left with a burst: two builds that asked the block what it saw¶
2026-10-13, for issue 361, the same trigger-on soak on the translated path as above, an hour at a time, on two builds of the working tree (neither committed):
| build | what it adds | passes | stalls in the hour |
|---|---|---|---|
d93f152c (main), the night before |
nothing | 349 | 3 |
c3d2f133 |
RESEND (R_OUT_CNTS bit 59) written when a visit leaves a ring, the vendor host's end-of-poll write; dp.resend, dp.resend_writes (on by default in the three instrument builds; off by default in the committed driver, 2026-10-14, because of this row) |
338, 923,193 writes | 5 - rings 5, 4, 2, 7, 4; idle 33 to 1,179 ticks; first passes 37 to 49 |
9df38c7b |
R_OUT_INT_STATUS, R_OUT_CNTS and R_OUT_INT_LEVELS read before every watchdog visit and kept with the stall record |
362 | 1 - ring 0, first pass 56, idle 128 ticks, visit 67 |
e13e7292 |
the ring's MSI-X vector read the same way - table mask bit, PBA pending bit, index, handler entries - and dp.istat_reset, passes that found the status set again right after clearing it |
133 in 22 minutes (cut by the bench) | 1 - ring 5, first pass 33, idle 309 ticks, visit 33 |
So RESEND changes nothing, which says the count was not sitting at the level when a burst began - the ring was empty, the burst crossed the level, and still no handler ran within a watchdog period. And the one stall the third build caught says why, in the block's own registers as they stood before the watchdog touched the ring:
int_status 0x1: the block's latched interrupt status was set. cnts: 0x38 = 56 packets in
the low word, the register's own "output interrupt" flag (bit 62) set in the high one. levels: a
packet threshold of 1. The condition was there, latched, for 128 ticks with 56 packets behind it,
and the handler did not run. This is not the block failing to raise the interrupt; it is the
interrupt raised and not acted on - the MSI-X message not delivered, or delivered to a vector that
did not run the handler. The next instrument is on that side: the vector's MSI-X mask bit and its
PBA pending bit and the vector's count from the kernel, read at the same moment, and a second read
of the status right after a visit's clear, for a clear that lost a race with the block setting it.
The fourth build read exactly that, and its one stall says:
before it: int_status 0x20 cnts 0x40001ccf00000021 levels 0x5600000001
vector 21 mask 0 pending 0 handler entries 88541
Ring 5's status set, 33 packets against a threshold of 1; the ring's vector (16 + 5) not
masked and nothing pending in the PBA. No message was in flight and none was held back by
the host. And the new counter says why a set status is not by itself a message: over the 37
minutes to the end-read, istat_reset rose by 328,303 against 256,384 interrupts taken - more
passes found the status set again right after clearing it than there were interrupts. The
write-one-to-clear is not final while the count stands at or above the level: the block sets it
again at once, or swallows the clear. The message comes on the status's edge, and a status that
never went down gives no edge. So the stall is a ring whose status never went down between a
visit's last clear and the burst - where in the visit that happens is what the next instrument has
to catch: at every visit's end, the status and the count as the visitor leaves them, and the
vector's entry count, kept per ring and printed with the stall, so the record says whether the
handler ran at all between that visit and the watchdog's. Every pass of all four runs was clean,
so the net under this is still a net.
The lab for these: raised 20:12, 21:28 and 22:48 (PC clock) with the two temporary rules, down 21:18, 22:29 and 23:26, zero leaves differing each time, the WAN-path tunnel re-initiated. The fourth run was cut at 23:10 by the bench - the soak's source address was moved to another appliance's port for that appliance's work - so its 133 passes are 22 minutes, not an hour.
The fast path's give-backs across the first half (#315): 3,741 times the retransmission rule gave a connection back and 3,740 times the driver revived it in place - the one not revived a connection at its end - with 843,479 frames handed up while the connections waited, 225 a give-back on average. 2,626 flows were made for 524 downloads over three loops: about five a download, which is the give-backs' share - one new flow for a connection taken out and attached to again - against the 3,741 revivals that cost no new flow.
The watchdog under a gigabit through the host (#320's second and third sections). With the
trigger off and three streams through the host - 81,000 frames a second - oq_declined rose at 13
a second (+70,306 over the half): the watchdog finding a ring's handler at work and leaving its
note. With the trigger on it was 0.7 a second. rxwd_runs +42,263 and rxwd_rescues +50,752 over
the same half are the watchdog arriving on a loaded ring between an interrupt and its handler, and
rxwd_stalls stayed 0: no ring was ever left with packets in it. oq_handed +4,031 is a servicer
finding such a note on its way out and going round again. oq_bound did not move all day, 126
before the first gigabit row and after the last: no visit reached its bound, so the regime of the
third section was not entered at a gigabit, and the RESEND question of the fourth stays a
reading.
The peer: alias, route, the two servers and the temporary rule in place 17:15 to 20:29; the rule
dropped by a filter reload (0 left, 0 states to the alias), the alias and the route gone, /root/lab
gone, zero leaves differing, its WAN-path tunnel re-initiated from its side. The longest raise of
the lab so far, three hours and fourteen minutes.
How to take these again¶
Everything here is taken with four things in tools/, which are meant to be copied to the appliance and run there:
blast.c |
a UDP sender that does not wait, and paces itself when given a rate |
octep-fpcnt.sh |
the coprocessor's 182 counters, with their names |
octep-fairness.sh |
the sweep above: what load on one port costs the others |
octep-soak.sh |
hours of traffic, with a leak, a stall and a starvation test in the log |
fpcntr-names.txt is the list those names come from - the enum in the shipped module's DWARF, in
the array's own order. Nine of its indices were already known from live traffic before the list
existed and every one of them lands on the right name, which is the reason to trust the other 173.
Without it a counter read is a column of integers: index 18 means nothing until it reads
FPCNTR_TX_WIRE_ERR.
The generator binds a source address, sends to a destination port nothing listens on, and never waits:
Point it at a port with no cable, with an address on that interface and a static ARP entry for a neighbour that does not exist, so nothing is ever asked for and nothing leaves the appliance:
ifconfig oxpN inet 198.51.100.1/24 alias
arp -s 198.51.100.2 02:00:00:00:00:02
...
arp -d 198.51.100.2 ; ifconfig oxpN inet 198.51.100.1 -alias
Read the counters with netstat -i -b -n, and mind the columns: Ipkts is $5, Ibytes is
$8, Opkts is $9, Obytes is $11. $7 is Idrop, and reading it as Opkts once produced a
confident wrong conclusion that the transmit counters were dead.
The far side's named counters come over rpc command 44 with rpc.num_entries set - see
octeon-tx.md for the reader and for the 182 names in order.
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.