← Back · supersedes the mechanism sections of Mach IPC reliability · see also namespace separation

Mach IPC: what has been eliminated

Six mechanisms measured clean, seven hypotheses dead, and a bug that is still open. This is the negative-results page — it exists so the next person does not re-tread ground that has already been settled by measurement.

2026-08-31. All figures from the arm64 UTM VM with both repos at main. Every claim here is a measurement, not a reading; where something is inferred it says so.

1. The bug, as currently characterised

A synchronous Mach round trip hangs, permanently, at a low rate.

propertyevidence
Rate~4–5%. Of 120 sends: 110 ok, 10 hung, 0 other failures.
Genuine hangsevery failure is rc=124 at exactly 6s; an earlier run survived a 90-second timeout unchanged
Flat, never converges30 blocks × 10: 0 1 0 2 1 2 0 0 1 2 0 1 1 0 0 0 2 0 0 0 1 1 0 0 1 0 0 0 1 0
Rate-sensitiveback-to-back 4/60 vs 1s apart 1/60
Concurrency does not hurt4 parallel workers, 60 sends, 0 failures
Vanishes under tracingktrace and LAUNCHD_TRACE runs both stopped reproducing it
Not notifydwith notifyd 0/40, without notifyd 0/40

Each syslog -s performs ~27–32 Mach RPCs. Solving 1-(1-p)^27 = 0.04 gives p ≈ 0.0015: roughly one RPC in 700 hangs, and it can be any of them. This was never a logging bug — which is why every path-specific fix measured exactly nothing.

2. Mechanisms measured clean

These are not arguments. Each is a counter compiled into the kernel and read against a workload that reproduced the failure.

mechanismcounterresult
Lost wakeup, port-set receivemach.lost_wakeups0 (four runs)
Lost wakeup, bare-port receivemach.lost_wakeups_bare0
Silent drop, inactive destinationmach.send_dead_port0
Silent drop, circular messagemach.send_circular0
Handoff wakeup losthandoff_posted vs handoff_receivedno positive gap
Delivery accountingdeliver_calls / _handoff / _enqueue1050+309 vs 1357 — reconciles

The last one matters as a control: it shows the counters themselves are trustworthy, because the two arms add up to the call count.

Why the first two were less conclusive than they looked

mach.lost_wakeups instruments only ipc_mqueue_pset_receive(). The client waits for its reply on a bare port, so three clean runs of it said nothing about where the hang manifests. That is why lost_wakeups_bare exists. Both are blind by construction to the direct handoff path, where ipc_mqueue_run() takes the message off the queue and into the receiver’s ith_kmsg — which is why the posted/received balance exists. Each counter was added to close a blind spot in the previous one.

3. Seven hypotheses, each killed

#hypothesiskilled by
1notifyd is causalwith/without both 0/40 on main. It was causal on the old userland — that result did not generalise.
2EVFILT_MACHPORT bridge misses wakeupsnotifyd-only RPCs: 1 hang in 55; notifyd idle during a wedge
3launchd lacks upfront MachService receive rightssource: machservice_setup() creates them at plist-parse time
4Port names recycled without generations (decay model)the decay signature was an artifact of a bad userland; on main nothing converges
5Bare-port receiver on a set member never woken (#144)database_server receives on the set, so the precondition never holds
6Send silently dropped to a dead portsend_dead_port = 0 over 120 sends
7Handoff wakeup lostposted/received balance shows no gap

4. Located: a message stranded on the server, not the reply path

Section 4 previously said misrouting of the reply was the last candidate. That is now disproved. The reply path was never the problem: the request never gets processed, so no reply is ever generated. Every instrument up to that point had been aimed at the client’s reply port — the wrong end of the RPC, which is exactly why they all read clean.

What a live wedge looks like

A client caught mid-hang is parked in the kernel, not in userland:

392  I  thread_b  syslog -s -l Notice VALIDATE-PROBE
  thread_block+0x44 ipc_mqueue_receive+0x3c0 mach_msg_receive+0xd8 sys_mach_msg_trap+0x40

It sat there for 226 seconds and completed the instant one unrelated request was sent to the same service. The request had been sitting on the server’s port the whole time; the later message re-triggered the wakeup and drained both.

The stranded port, named

mach.port_backlog walks every space and reports each port holding an undelivered message, with the process owning the receive right. During a live wedge:

pid  comm    name  port              msgcount pset waiters rights recv-right-owner
131  syslog  20    ffffa000c40bf680         1  yes      no      S notifyd[31]
32   syslogd 22    ffffa000c40bf680         1  yes      no      S notifyd[31]
31   notifyd 19    ffffa000c40bf680         1  yes      no      R notifyd[31]
1    launchd 28    ffffa000c40bf680         1  yes      no      S notifyd[31]

One port, one message, four spaces. notifyd holds the receive right — and notifyd has no thread in ipc_mqueue_receive at all. Its Mach traffic is serviced entirely through EVFILT_MACHPORT on a kqueue.

Why the epidemiology finally makes sense

ObservationExplanation
Vanishes under tracingTracing perturbs timing and adds traffic that re-triggers the wakeup
Zero failures under concurrencyConcurrent sends continuously nudge each other
Rate-sensitive (4/60 vs 1/60)The slower the traffic, the longer a stranded request sits
Flat ~4%, never convergingA race on an idle path, not a resource that degrades

An isolated send on an idle system is precisely the one that strands, because nothing follows it to knock the server loose.

A second, permanent instance

aslmanager’s service port holds an undelivered message from boot, on every boot, across every kernel measured. Same signature: kqueue-serviced, no Mach receive thread. It requires no race to observe, which makes it the best starting point for the remaining work.

4c. Fixed, and re-measured: 22/80 → 80/80

The cause was one flag in userland. dispatch_kevent.c registered the EVFILT_MACHPORT readiness knote EV_ADD | EV_CLEAR, while the Phase 3 ERROR_EVENTS skip assumed level-triggered semantics — its own comment says “the next ordinary poll delivers it”. With EV_CLEAR the kernel consumes the readiness when it hands the event over (kern_event.c:2270), and nothing re-arms it, so the message waited on the port for unrelated traffic. Dropping EV_CLEAR makes the assumption true (PR #136).

testbeforeafter
original 8-boot harness22/8080/80
aslmanager frozen at bootevery kernel measuredgone
syslog -s failures9, 12, 5, 5 per 1500, 0, 0 per 150
indefinite strand, 60 attemptscaught at 2–31none

The raw harness reads 72/80, and that understates it. Every boot scored exactly 9/10 — not the shape of a random 4–5% fault. Per-send timing showed the first send after boot takes ~13.5s and succeeds, with the message confirmed in system.log; the harness’s 10s timeout was simply shorter than a slow cold start. That residual is a separate deterministic latency bug, filed as userland #137. Delivery is 80/80.

A criterion set in advance failed, and it was the criterion that was wrong. The commit required the kernel-side notify/drain gap to collapse toward 0; it rose from 10/12/11/6 to 26/24/29. Level-triggering re-reports the same readiness until drained, so rcvlarge_notify now counts re-notifications — psetport_drained is flat (~897 → ~907) while notify rose ~27. Nothing is lost; the metric stopped measuring loss. The deterministic aslmanager test and the failure counts are what carry the result, and both were fixed in advance.

4b. Resolved: the kernel notifies, userland does not always read

Two counters settle where the loss happens. rcvlarge_notify counts “a message is waiting on port N” events handed to userland; those do not dequeue anything, since filt_machport() returns with the message still queued and leaves userland to issue its own mach_msg(). psetport_drained counts messages actually taken off a port-set member port.

runnotificationsreadgapclient failures
1919909109
29018891212
3906895115
490189565

The gap is always positive: userland is told about ~1% more messages than it reads. Because the knote is EV_CLEAR, nothing re-arms it, so an unread message waits for unrelated traffic to signal the set — which is exactly the nudge behaviour that opened this investigation.

The gap is the same order as the failure count but not a strict 1:1 — run 3 is 11 against 5. An undrained notification only becomes an observed hang when a client is waiting on that particular reply. The defensible claim is the weaker one.

Carried to userland #135. The kernel side is finished.

5. Corrections to the record

Recorded because each of these was stated with more confidence than it deserved.

Eight mechanisms now excluded by counter

Each measured on a run that reproduced the failure:

MechanismReading
Send-path blocking0 entries in 150 sends
Threads stuck parkedenter/exit balanced
Scan missing a queued message0 finds across 3118 second passes
Wakeup not delivered897 sleepers found, 897 woken, of 901 enqueues
Port-set identity (recycled name)0 mismatches in 1823 evaluations
Name translation failure0
Notification’s port name wrong or null0 nulls in 920 notifications; ip_rcvname matches the entry name
Dead-port and circular drops0

6. Method: what worked and what did not

Every instrument that encoded an assumption went silent and taught nothing. Every instrument that just counted was right.

Against that: six counters, six unambiguous answers, all of which remain true regardless of what the bug turns out to be.

Two pieces of tooling also proved decisive and did not exist at the start: a serial console (TCP 127.0.0.1:1234; arm64 needs console="eficom,efi", not the x86 comconsole), and hardware verification before merge — CI’s boot smoke-test passed a kernel that then failed to boot the machine.

7. Where to pick up

  1. userland #135 — libdispatch’s EVFILT_MACHPORT consumer. Start from aslmanager’s permanently frozen port: no race needed, present on every boot, never resolves. Check whether the handler always issues the follow-up mach_msg() for the reported port, whether an error from it is swallowed, and whether anything re-arms after EV_CLEAR if the receive does not fully drain.
  2. userland #127 is now on the critical path, not a cost reduction. _asl_evaluate_send drives ~27 RPCs through this exact path per log line; cutting them shrinks the exposure proportionally before any real fix.
  3. Keep mach.port_backlog. It named the culprit outright when nine counters had only excluded things.
  4. Do not resume the namespace-separation plan. Its original rationale was retracted, and the elimination argument that replaced it is retracted too — port-set identity and name resolution both measured clean.
  5. Weight states over rates. Failure rates of 2, 3, 4, 6, 8, 9 and 11 per 150 were all observed on kernels that changed nothing relevant. The frozen aslmanager row is a state; the rate is a noisy sample.