five wrong theories about a raspberry pi wifi problem
The office clock kept falling off the network. Five theories died before I built something that could actually see the failure, and the thing that finally found it was a median.
Same box as always: the Raspberry Pi Zero W that has been running a clock on my office wall since 2016. Part 3 covers its 2026 software rebuild, and the read-only gotchas post covers making its root filesystem read-only. This one is about the week after, when it would not stay on wifi.
The symptom was simple and awful. Every so often the clock would vanish from the
network. The display kept running, the board was clearly alive, but nothing
answered – ssh said No route to host, which is ARP failing, which means it
was not on the network at all any more. Sometimes it came back on its own after
a few seconds. Once it stayed gone for six and a half hours.
I have a cron job called poke that pings the clock every fifteen minutes and
power cycles it through a smart switch if it does not answer. So in practice the
clock always came back. That turned out to be the whole problem.
The evidence was destroyed by the recovery
Here is the trap, and it took me embarrassingly long to name it out loud.
The root filesystem is read-only with overlayroot=tmpfs, so /var/log lives
in RAM. The recovery for a wedged clock is a power cycle. A power cycle wipes
RAM. Every single time the box failed, the thing that fixed it also deleted
the only record of what happened.
So I would come back to a healthy clock, run journalctl, and find a log that
started at the reboot. Nothing before it. Ever.
That is why I burned five theories:
- The wifi firmware was updated recently. It was not – I had misread the
mtime of a dpkg
.listfile. The actual blobs were weeks older. - A systemd watchdog was resetting the box.
bootstatus: 0. The reboots were my ownpokescript doing its job. - The kernel command line had changed. I compared a file on disk against
/proc/cmdlineand nearly presented that as a diff. They were byte-identical. - A stale lock file was latching the box into a reboot loop. Plausible, and a real bug – more on it below – but not this.
- Something in the logs was failing silently. The check I was relying on
turned out to be
file /var/log/syslog, andfileexits 0 whether or not the path exists. It had been validating nothing for years.
Five theories, all argued from fragments, none of them measurable. Every one of them was a story I told about a gap in the data.
So build something that survives the failure
The fix for that is not cleverness, it is a vantage point. Put the observer somewhere that stays up.
I already run Uptime Kuma in a container on my Proxmox box, and that container
can reach the clock. So it got a small flight recorder: a shell loop that, every
five seconds, sends one ICMP echo and opens one TCP connection to port 80,
and every thirty seconds fetches the clock’s own /status.json. All of it
read-only against the clock – nothing installed on the Pi, nothing written to
it, no new firewall rule, because port 80 was already open to that subnet.
Five seconds mattered. The outages I eventually caught were as short as one
second, and the whole reason poke had never reported a problem is that a
fifteen-minute sampler essentially never lands inside a five-second window. Both
observers were telling the truth. Only one of them could see.
The other design decision that mattered: record latency as a number, not as a verdict. My first instinct was a short HTTP timeout and an up/down boolean. That would have thrown away the entire answer, because the clock was rarely down. It was slow.
The median was the answer, and it was hiding in plain sight
After a night of recording, the table looked like this. p50 is the median
round trip in milliseconds.
HOUR SAMPLES ICMP% RTT_p50 RTT_p90 RTT_p99 RTT_max
21 718 100.0 6.09 10.4 40.3 391
22 718 98.9 48.9 465 2025 2758
23 719 100.0 126 527 977 2205
00 718 99.9 127 554 1319 2286
03 715 99.2 99.2 390 1033 1772
06 717 100.0 90.8 339 737 1012
Six milliseconds in the evening. A hundred and twenty in the middle of the night, for eight hours straight, at a rock-steady -45 dBm with zero missed beacons and essentially no packet loss. Not down. Just twenty times slower, and only when nothing else was talking to it.
An 802.11 beacon interval is 100 TU, which is 102.4 ms.
A station in power save mode does not receive unicast frames while it is asleep. It wakes on a beacon, checks whether the access point has anything buffered for it, and only then collects it. So the round trip is not “slow wifi” – it is being quantised to beacon boundaries. A median sitting on one beacon interval is not a coincidence, it is a fingerprint.
And it explains the correlation that nothing else explained: the degradation tracked network idleness, not time, temperature, signal, or load. Evening traffic kept the radio awake. Overnight, every probe paid the full beacon tax.
The clock’s own kernel log had been saying so from the beginning:
brcmfmac: brcmf_cfg80211_set_power_mgmt: power save enabled
Turning it off, and the check you must not skip
NetworkManager can disable it. The connection here is generated by netplan and could be regenerated, so the right place is a drop-in rather than the connection profile:
# /etc/NetworkManager/conf.d/10-wifi-powersave.conf
[connection]
wifi.powersave = 2
0 = use default, 1 = leave alone, 2 = disable, 3 = enable.
Now the trap. After applying that, nmcli reports:
802-11-wireless.powersave: 0 (default)
That looks like it did not work. It is correct and expected – the value arrives
as a default from conf.d, so the per-connection property legitimately stays
unset. The authoritative check is the kernel:
$ sudo dmesg | grep power_mgmt
[95.645394] brcmfmac: brcmf_cfg80211_set_power_mgmt: power save enabled
[98.587963] brcmfmac: brcmf_cfg80211_set_power_mgmt: power save disabled
NetworkManager enables it at association, then the drop-in turns it off three seconds later. If you do not see that second line, nothing happened, no matter how green everything else looks.
There is a bonus in doing it this way. Because it is a default rather than a connection property, it re-applies automatically any time the driver reloads – which matters later.
The result, over the following days:
| power save on | power save off | |
|---|---|---|
| RTT p50 (idle hours) | 99-127 ms | 6.0 ms |
| RTT p90 | 340-554 ms | 10.3 ms |
| RTT p99 | 740-1320 ms | 33-54 ms |
| ICMP success | 99.0-99.9% | 100.00% |
Two consecutive full days at 100.00%, out of seventeen thousand pings each.
The part where I was wrong
This is the honest bit, and it is the reason I did not stop here.
I wrote “power save was the cause” in my notes. It was not – or rather, it was the cause of the latency, which is a different claim from the one that mattered. The clock was still wedging.
The six-and-a-half-hour outage happened with power save already disabled. I
was able to prove the setting had really been in effect, and prove it after the
fact, because the flight recorder had been storing the clock’s own
/status.json every thirty seconds – and that JSON contains a couple of
monotonic counters. A reboot resets them. They never reset. The box had not
restarted, so the runtime setting had not silently reverted.
That is the payoff of storing the whole payload instead of the two fields I thought I needed at the time.
The run-up to that outage is the clearest picture I have of the real failure:
00:34:55 icmp 6.22 ms tcp 5 ms
00:35:00 icmp 5.69 ms tcp 5 ms <- perfectly healthy
00:35:12 icmp FAIL tcp FAIL <- gone, for 6h37m
No ramp. No rising latency, no loss creeping up. One sample healthy, the next silent, and no link-layer event anywhere in the driver log. That is a chip or driver wedge, and I still do not know what causes it. Old Broadcom firmware from 2021 with no diagnostics is a bad thing to go hunting.
So I stopped hunting and made recovery cheap instead.
A watchdog, and how it lied to me
The watchdog is thirty lines of shell. It pings the gateway every 30 seconds. At
two minutes with no answer it asks NetworkManager to re-associate. At four
minutes it reloads the wifi driver. It never reboots – poke is still the
outer backstop – and it will not act at all until it has seen the gateway work
once, so a slow boot can never trigger a driver reload.
Then I did the thing I very nearly skipped: I tested it.
Test one, taking the interface down, passed exactly as designed. Test two, the driver reload, printed this:
modprobe -r brcmfmac -> REMOVE FAILED
modprobe brcmfmac -> reloaded OK
Read those two lines together. The unload failed and the reload “succeeded.”
dmesg settled it: the brcmfmac initialisation lines were still timestamped
[49.x], from a boot three and a half days earlier. The driver had never been
unloaded or reloaded. What actually restored the network was the nmcli
reconnect at the end of the function – the hard recovery had silently decayed
into a second copy of the soft recovery, which had already failed.
Two separate bugs, stacked.
The first:
brcmfmac_cyw 12288 0
brcmfmac 331776 1 brcmfmac_cyw
modprobe: FATAL: Module brcmfmac is in use.
Recent kernels split out brcmfmac_cyw, which depends on brcmfmac.
modprobe -r unloads what your target depends on, never what depends on
your target. So removing brcmfmac by itself can never work here. You have to
take brcmfmac_cyw first.
The second is the one worth carrying around:
modprobe <module>returns 0 when the module is already loaded.
So a failed unload followed by a no-op load is indistinguishable, by exit code,
from a real reload. My watchdog checked those exit codes and reported
reloaded OK, firmware re-downloaded while doing absolutely nothing. In a real
wedge it would have logged success and left the clock dead until poke pulled
the power.
That is the worst failure mode a safety net can have. Not “it did not work” – “it did not work and told me it did.”
Verify the end state, not the exit code
The fix is to remove the dependent module first, and then to stop believing return values when a direct check is one command away:
modprobe -r "${MODULE}_cyw" 2>/dev/null
modprobe -r "$MODULE" 2>/dev/null
if lsmod | grep -q "^${MODULE} "; then
log "ERROR $MODULE STILL LOADED after unload -- this recovery did NOTHING."
return 1
fi
log "$MODULE unloaded (verified via lsmod)"
modprobe’s exit code meant nothing. lsmod meant everything. Same shape as
the dmesg check for power save earlier, and the same shape as that
file /var/log/syslog check that had been passing unconditionally for years.
Three times in one project, the trustworthy signal was a direct observation of
the end state and the untrustworthy one was a status code.
And the proof that the fixed version really works is itself an end-state check:
sudo dmesg | grep -c "F1 signature read"
That counts fresh chip probes. One at boot, plus one for every genuine driver
reload. It went from 1 to 2, with the firmware re-download logged in between,
followed by power save disabled re-applying itself from the drop-in. Real
recovery time, end to end: about ten seconds.
Two smaller things that fell out of this
A lock file with no owner will eventually hurt you. My poke script touched
/tmp/badoclock before recovering and removed it after – but with set -o errexit in front of three fallible network operations, any failure left the
latch behind forever. After that, every run skipped the health check and went
straight to power cycling. I found one sitting there, hours stale. The fix is a
trap ... EXIT INT TERM so it always clears, plus an age check so a stale latch
expires on its own rather than waiting for a human to notice.
Fixing one thing exposes the next one. With the wifi stable, the clock ran for three and a half days – easily its longest stretch of the whole saga – and a slow memory growth in one of my own Python services became visible for the first time. It had always been there. Frequent reboots had just been hiding it. I have not touched it yet, because the shape of the curve (a full day at exactly zero change, then a step) looks much more like CPython arena behaviour settling into a working set than a real leak. It is being watched, with a number written down that would make me act.
So was this known? Yes. For about ten years.
I went looking afterwards, expecting to find nothing, and instead found the thing already written down – by Raspberry Pi, in their own kernel tree.
Commit 1f13305, by Phil Elwell:
Disable wireless power saving in the brcmfmac WLAN driver. This is a temporary measure until the connectivity loss resulting from power saving is resolved.
It force-set enabled = false inside brcmf_cfg80211_set_power_mgmt and
printed power management disabled, overriding whatever userspace asked for.
The file it patches is drivers/net/wireless/brcm80211/brcmfmac/cfg80211.c –
and that path moved to drivers/net/wireless/broadcom/brcm80211/ in kernel 4.7,
in mid-2016. So the commit is older than that.
It is not in the kernel my clock runs. I can prove that from the log I have been
staring at all week: mine prints the stock driver strings, power save enabled
and power save disabled. The patched driver printed power management disabled, and with it in place power save could never have been switched on at
all.
Read that sequence again, because it is the actual story here. The exact failure was found. It was acknowledged by the vendor of the board. A fix was shipped, explicitly labelled temporary, pending a real one. The real one never arrived, the temporary one was quietly lost somewhere in ten years of rebasing, and the default went back to the setting that causes the problem.
A temporary measure that outlived its own removal.
Meanwhile raspberrypi/firmware#1973 is open, on the same BCM43430,
with power save enabled all through the logs. Module parameters were tried
there (feature_disable=0x2000) and did not help. The forums have years of
threads about this chip dropping off wifi, and most resolve the same way: a
stranger says turn power save off, the thread gets marked SOLVED, and nothing
goes anywhere.
And while I was writing this, somebody opened raspberrypi/linux#7533 – Pi Zero W and Zero 2 W, same kernel series, random disconnects, no log entries before the failure. Twelve days old when I found it, no maintainer reply. They had already tried disabling power save, and it had not helped.
That last detail is the one I would have misread a week ago. It looks like evidence that power save is a red herring. It is not: it is evidence that two different bugs are wearing the same symptom. Power save explains the latency, demonstrably and reversibly. It does not explain the disconnects – I have those with it off too. Everyone hitting this ends up arguing about a single “wifi is flaky” and getting contradictory results, because half of them measured one bug and half measured the other.
And the wedge is known too, which is the bad news
Everything above is about the latency. The other failure – the radio going silent until someone cuts power – is also documented, and reading it made me glad I did not delete the smart switch.
openwrt/openwrt#23069 is the most careful writeup of it I found. Same driver, newer chip (BCM43455, on a Pi 5). Once it enters its terminal state:
rmmod brcmfmac; modprobe brcmfmac– “same timeout”, every attempt- unbind/rebind the SDHCI platform driver, watching the regulator actually cycle – still times out
- a software
rebootdoes not clear it - only “physical PSU unplug + replug recovers”
So there is a state in which my watchdog does nothing, a reboot does nothing,
and the only thing on the list that works is removing power. That is what poke
has been doing this whole time with a smart switch, and it turns out to be the
load-bearing layer rather than the crude one.
There is also a thread on Infineon’s own forum about a CYW43455 getting stuck
with brcmf_sdio_bus_sleep error while changing bus sleep state -110,
needing a module reload to recover. What is interesting is the path in: the bus
got stuck during a power-save transition. Which is at least a plausible thread
connecting the two halves of my week – fewer sleep transitions, fewer
opportunities to wedge the bus. I want to be careful there, though: I cannot
show that from my own data. I had one wedge after disabling power save and none
since, and one event is not a rate. Turning power save off demonstrably does
not eliminate the wedge. It fixed the latency, and that is all I can claim.
Why nobody is fixing it
brcmfmac is vendor-maintained. Infineon (who inherited these parts from
Cypress, who got them from Broadcom) ships a large stack of patches
explicitly marked non-upstream, and is working on a replacement driver
for mainline rather than fixing the old one. The chip in my clock shipped in
2016 and its firmware is dated July 2021. It is nobody’s priority, and honestly
I understand why – it is just strange that the last word on it is a
decade-old commit saying “temporary.”
So the complete set of documented recourses, as far as I can tell, is three items long, and I am running all three:
| what it is | how much I trust it | |
|---|---|---|
| Disable power save | a conf.d drop-in |
fixes the latency, measured |
| Reload the driver | the watchdog | works on a healthy chip; unproven on a wedged one |
| Cut the power | poke and a smart switch |
the only one that always works |
No fourth idea exists that I could find. If you know of one, I would genuinely like to hear it.
What I would tell myself at the start
Put the observer where the failure cannot reach it. Record quantities, not verdicts – I would have missed this entirely with an up/down check, because the clock was almost never down. Sample fast enough to see the events you are chasing, then keep the raw payload so that the question you have not thought of yet is still answerable in three days’ time.
And test the recovery path. A watchdog you have never fired is not a safety net, it is a belief.
And when you finally fix something, go and look for who else hit it. I assumed that step would be a formality. Instead it turned a private annoyance into a much stranger fact: this was found, acknowledged and patched before the clock on my wall was two years old, and the patch is gone.
If something here was not well described, don’t be shy about reaching out.
Enjoy!