What a macOS laptop does: sleep, wake, and changing networks
CI proves that a Mac can bring a tunnel up, resolve names, claim a default route and refuse egress that does not go through it. It proves all of that on an idle hosted runner, in under a minute, on a machine that never sleeps and never changes networks.
That is a real proof and it is not the case anybody will actually run. A macOS device is a laptop: it closes, it moves between wifi and a phone hotspot, it joins a corporate VPN that claims the default route. Hosted runners do none of those things, which is why this is a document somebody follows rather than a job that runs (#160).
Run this before tagging a release, and before merging anything that touches
internal/wglink, internal/pf or internal/resolved on macOS. Record the results in the
table at the bottom. An empty row is not a pass.
What you need
- A control plane the laptop can reach over the internet — not a loopback one. Half of
what is being tested is what happens when the laptop's route to that address changes, and
a control plane on
127.0.0.1cannot exercise it. - The Mac enrolled in a network, with
meshp statusreporting a tunnel whose kind isuserspace. - A network with a route group carrying
0.0.0.0/0assigned to this device, andfail_closedenforced. Without that, none of the egress behaviour is reachable. - Two networks you can move between. Wifi and a phone hotspot is the easiest pair, and a hotspot is also the more honest test: a different gateway, a different address family situation, and often carrier-grade NAT.
What to look at
Keep these in three terminals. Everything here works on a machine with no internet, which is the state some of these steps produce.
meshp status
sudo meshp doctor
netstat -rn -f inet | head -20
sudo pfctl -a com.apple/meshp -s rules -v
The daemon's own log is the fourth thing worth having, and where it goes depends on how meshpd was started. Run it in a terminal for these checks rather than under launchd, so the log is in front of you.
The checks
1. Sleep and wake
The tunnel lives inside the agent's process, so it survives a sleep only if the process and its sockets do. macOS suspends processes and tears down sockets aggressively.
- Confirm
meshp statusshows the tunnel up and a live session. - Close the lid. Wait at least five minutes — a short sleep does not reach the same code paths as one long enough for the control channel to time out server-side.
- Open it.
- Watch
meshp statuswithout touching anything else. That is the whole test: recovery has to need nothing from the person carrying the laptop.
Pass: within one reconcile interval (a minute by default), the tunnel is up, the session
is connected, and peers handshake again. meshp doctor reports nothing stranded.
Record: how long recovery took, and whether anything needed a nudge.
2. Changing networks
A new address means the tunnel's outer endpoint has moved. Because meshp is relay-first (ADR-0002), the relay connection has to be re-established rather than a direct path renegotiated — which should make this easier than it is for other implementations. "Should" is doing the work in that sentence, which is why this check exists.
- On wifi, with a full tunnel claimed, note the default gateway:
route -n get default | grep gateway. - Note the carve-out routes meshp installed:
netstat -rn -f inet | grep -E '^(0\.0\.0\.0/1|128\.0\.0\.0/1)'and the host routes to your relay and control plane. - Turn wifi off and tether to a phone.
- Watch
meshp statusand the routing table.
Pass: the carve-out routes point at the new gateway, the relay reconnects, and the tunnel comes back — again with nothing done by hand.
Watch for: a host route to the relay or the control plane still pointing at the old gateway. That address is not reachable on the new network, and with fail-closed egress in force the laptop has no way out at all until it is corrected. This is the specific thing that makes the reconcile interval matter more on macOS than on Linux: the carve-out is routed rather than marked, so it is pinned to whatever gateway was current when it was made.
That correction was broken until #160 and is worth checking rather than assuming.
route add reports an existing destination as "File exists" without touching it, so every
re-claim was told the route it wanted was already there and the stale one survived. It is
corrected in place now. The remaining exposure is the interval itself: nothing watches
for a network change, so the gap between moving and recovering is however long it is until
the next pass, up to a minute by default. Time it. If it is much longer than that, the
correction is not happening and this check has found the same class of bug again.
3. An ungraceful death while a full tunnel is claimed
ADR-0011 keeps the lock installed across a crash on purpose, and Invariant 20 says the agent must never leave a host without networking. Those two pull against each other exactly here.
- With a full tunnel claimed and failing closed,
sudo kill -9the daemon. - Before restarting anything, run
sudo meshp doctor. It should say the machine is refusing traffic and that meshpd is not running, and print the commands to undo both halves. - Check what is actually left:
sudo pfctl -a com.apple/meshp -s rulesandnetstat -rn -f inet. - Start meshpd again and watch its first few log lines.
Pass: the daemon reports what it found and removes it — both the pf anchor and the routes — and the machine has ordinary networking within a reconcile interval.
Watch for: the daemon logging that it found a claimed default route and leaving routes
behind anyway. Releasing the routing is the half with no equivalent of the firewall mark
Linux uses to find its own work again: a restarted daemon reads
/var/run/meshp/egress-routes, which the claim wrote, and takes off exactly what is named
there. Check that the file is gone afterwards. If it is still present, the release did not
complete and the next start will try to delete routes that may no longer be meshp's.
4. A VPN that claims the default route
Now that macOS fails closed (ADR-0026), this interaction is worth knowing rather than guessing at. Another VPN and meshp both want the default route, and both may be enabling pf.
- With meshp holding a full tunnel, connect a corporate or commercial VPN.
- Check
netstat -rn -f inetfor whose routes won. - Check
sudo pfctl -s infofor the enable reference count, andsudo pfctl -s rulesfor whether thecom.apple/*anchor point is still there. - Disconnect the other VPN and check that meshp's lock and routes are intact.
Pass: whatever happens to routing, meshp's anchor is still nested where it can be evaluated, the pf reference count is not left at zero with meshp still expecting it, and disconnecting the other VPN does not leave this machine locked with nothing to carry traffic.
Record what happens even if it is bad. This is the one check whose purpose is to find out rather than to confirm.
5. connected_since after the laptop sleeps
Server-side, and the reason it is here: a laptop that sleeps without closing its session
leaves membership_state.connected_since set, so the device reads as connected on every
replica until the session times out.
- Note the device as connected in the API.
- Sleep the laptop for longer than the session timeout.
- Poll the device's state and record how long it goes on claiming to be connected.
Pass: it stops reading as connected within the timeout, and the bound is what the code says it is.
Results
One row per build actually exercised. A release without a row for its build has not had this run.
| Date | Build | macOS | 1 Sleep | 2 Networks | 3 kill -9 | 4 Other VPN | 5 connected_since | Notes |
|---|---|---|---|---|---|---|---|---|
| 2026-08-31 | v0.1.0-57-geb4d461 | 26.0 (25A354), MacBookPro17,1 | pass | not run | not run | not run | fail | First run of this document. 2–4 need an egress route group that this deployment does not have. See below. |
2026-08-31, v0.1.0-57-geb4d461
Against control.meshp.net over the internet, relay-only paths, no route groups configured.
1 Sleep and wake — pass. Lid shut at 13:23:44Z and opened around 13:33:30Z, so nearly ten minutes asleep. The agent re-established its control channel at 13:34:53Z with no help, well inside the one-minute reconcile interval, and traffic was flowing again by 13:35:15Z. The tunnel had kept carrying packets throughout, which is what WireGuard being stateless UDP buys and also what makes this check worth doing from both ends: the data plane working proves nothing about whether the device is still being configured.
Worth knowing for whoever runs this next: for about ninety seconds after waking, meshp status
still reported connected since the pre-sleep session, which by then the control plane had
already dropped. It is not a lie so much as a thing not yet noticed, and it corrects itself
within the interval — but somebody debugging in that window will be shown a session that no
longer exists, and should look at the server rather than believing it.
5 connected_since after sleep — fail. The bound is wrong, not the behaviour. The control
plane went on reporting the laptop as connected for roughly 9m35s after the lid closed,
against a readIdleTimeout of 90 seconds in internal/session/server.go. It did clear
eventually, so nothing is stuck; what fails is this check's second clause, that the bound is
what the code says it is.
For those ten minutes the overview endpoint, the page, and anything built on that API would have shown a device in a closed bag as connected and healthy. That is the failure ADR-0022 §4 is about — a monitoring view that lies about when it last heard anything — arriving through the server rather than through the page.
The likely cause, which is a hypothesis and not a measurement: macOS keeps the socket alive through light sleep, so reads keep succeeding and the deadline never trips until the machine sleeps properly. If that is right, a read deadline is the wrong mechanism on a laptop and meshp needs liveness it drives itself. Somebody should establish which it is before choosing a fix — the number to beat is 90 seconds, and the observed one is six times that.
2, 3 and 4 — not run. All three need a route group carrying 0.0.0.0/0 assigned to this
device with fail_closed enforced, and the deployment this was run against has no route groups
at all. They are not failures and they are not passes; nothing exercised them.
Rendered from docs/testing/macos-laptop.md, which is where it is edited.