← Back · implements the fix identified in Mach IPC reliability · see also EVFILT_MACHPORT, Launchd → Mach

Mach namespace separation

Stop Mach port names being file descriptors. This is the single deviation from XNU that causes non-deterministic Mach message loss on NextBSD, and undoing it is a two-stage kernel project.

2026-08-31. Change maps produced by two scoping agents against nextbsd-kernel@7ccd489, with every load-bearing claim re-verified by hand. Line numbers cite main. See §11 for a methodology correction that affects how parts of the agent output should be read.

Priority. Mach IPC drops messages non-deterministically. Every daemon inherits it, and it makes every other component’s behaviour unreproducible — every userland measurement taken so far was contaminated by it. This outranks further userland porting.

0. Progress

StageTicketStateMeasured
1 prereq — ipc_pset_alloc_name init#146merged
1 — EVFILT_MACHPORT off the fd table#147merged35/100 (unchanged, as predicted)
2 / C0 — reverse hash off is_table#148merged
userland — MACH_PORT_INDEX correcteduserland#131merged
2 / C1 — locking discipline#149folded into C2b
generation counter (partial C2b)#160merged, then REVERTED — does not bootn/a
fileport generation guard#161open, inert until generations return
2 / C2a, C2b, C3–C6#150–#155open

Stage 1 is done and is behaviour-neutral, which is the correct result. Kernel 20260831-021508 on arm64 measured 35/100 — identical to the pre-fix baseline and to #144. It removes the kqueue filter’s dependence on file descriptors; it does not change what a name is, so it cannot fix a stale-name bug. What matters is what did not happen: f_isfd = 0 boots, every service reports status 0, and the two changes that would have surfaced as a use-after-free or a “recursed on non-recursive mutex” panic are holding under a real launchd/libdispatch workload.

A userland change had to land first, and it was not obvious. Kernel CI boots against the published userland, so a kernel-only generation change would pass its own smoke-test and still break the box: launchd sized mig_cb_table by the raw port name, and a generationed name would ask PID 1 for hundreds of megabytes. userland#131 fixes that and is deliberately a no-op today (generation bits are zero, so MACH_PORT_INDEX(name) == name).

It also repaired a latent bug: MACH_PORT_INDEX was (name) & ~0xff, which returns zero for every port this kernel allocates — so launchd’s HASH_PORT and libdispatch’s VL_HASH put every port in bucket zero.

The generation counter passed CI and did not boot on hardware. Merged as #160, deployed as 20260831-025635; the machine never came back — no ssh, no ping, and a clean stop/start did not help. Recovered via LiveCD by restoring the Stage 1 kernel, which came back in 10 seconds with every service healthy. Reverted in #162 because main otherwise ships an unbootable kernel.

Two process failures, each worth more than the patch:

  1. A green CI smoke-test is not authorization to deploy. It boots a self-assembled image under qemu/TCG; the box is an arm64 UTM VM with different disk layout, device set and timing. Something in that gap is fatal here and invisible there. Kernel changes of this class need hardware verification before merge, on a branch.
  2. There is no serial console, so the failure is silent. utmctl attach is not implemented — it prints a warning and echoes the controlling terminal. No panic was recoverable, so a panic cannot be distinguished from a hang, and every failed boot costs a full LiveCD recovery cycle that teaches us nothing. Getting a console is now a prerequisite for C2b, not a nicety.

Prime suspect, unverified: ipc_entry_alloc_name() is reachable from ipc_object_copyout_name() on the live mach_msg path and already double-allocates, returning a name that does not match the one requested. #160 called that “pre-existing, not made worse” — probably wrong. Under generations the mismatch also carries a generation mismatch, so lookups that previously limped along on the placeholder entry fail outright, breaking early-boot IPC while leaving the narrower qemu path unaffected. If so, the fix is to make alloc_name honour the requested name — which is exactly C2b, and confirms it cannot be fixed independently of the table.

1. What we are fixing

In XNU a port name is an index into the task’s ipc_space entry table plus a generation counter, and the Mach namespace is entirely separate from the BSD file-descriptor namespace. In this port names are file descriptors, allocated by kern_fdalloc(td, 16, &fd) (ipc_entry.c:453) — lowest-free-at-or-above-16 — and the generation machinery is compiled out (sys/mach/port.h:219, MACH_PORT_GEN(name) is (0)).

Consequence: a name freed by one RPC is handed back bit-identical to the next, and nothing can tell that a cached name now denotes a different object. ipc_object_copyin() then wraps any non-Mach fd into a synthetic port with ip_receiver set and nobody receiving on it, so the send succeeds and the message is swallowed.

Measured on arm64 hardware

PropertyEvidence
Permanent wedge, not slowness90-second timeout expires unchanged; 5 of 13 sends burned the full 90s
Bimodalsub-second success or never; no intermediate durations
Client and server both blocked at onceprocstat -kk: client in ipc_mqueue_receive, server thread also parked in ipc_mqueue_receive
Traffic-driven decay40–60% wedge from cold boot → 0 after ~70–80 sends, then stays 0
Time-independent10 minutes idle, zero sends → still 15/30 wedged
Resets on daemon restartwarm 0/20 → restart syslogd → 4/20; re-warm → restart notifyd → 10/20

The decay is the diagnostic property: two name leaks (§7) push the fd low-water mark monotonically upward, so the recycled band and the live band separate and collisions stop — permanently, because fds never go back down. A daemon restart resets the table to 16 and recreates the dense band.

2. nextbsd#369 was closed for the wrong reason

nextbsd#369“Flaky amd64 boot tests: notifyd/syslogd ASL round-trips stall in mach_msg_receive under TCG emulation” — is this bug, and it was closed as emulator flakiness. It is not: it reproduces on real arm64 hardware, survives a 90-second timeout, and has an identified cause in ipc_entry. Emulation only made it easier to hit. That misattribution is why it went unfixed.

3. Why it is two stages

The obvious fix — turn the generation counter on — does not work in isolation, and this was verified by writing it. machport_filtops has .f_isfd = 1 (ipc_pset.c:537), so FreeBSD’s kqueue core calls fget(kev.ident) before the filter attaches. libmach registers EVFILT_MACHPORT with the port name as the ident (dispatch_kevent.c:434). Put generation bits in a name and that fget returns EBADF — EVFILT_MACHPORT registration dies, taking libdispatch’s Mach bridge with it.

The written patch is preserved on fix/port-name-generation, committed with a message stating it must not be merged. It becomes correct once Stage 1 lands.

4. Stage 1 — make the kqueue filter stop depending on fds

Goal: the filter resolves the port set from the calling task’s ipc_space, with no dependence on the fd table. Names are still fds after Stage 1; nothing may yet change what a name is. Behaviour should be unchanged — this stage only removes the dependency.

4.1 Change map

SiteTodayAfter
ipc_pset.c:537.f_isfd = 1.f_isfd = 0. This one token flips five kqueue-core behaviours at once: lookup, expand, storage, fdclose, refcount.
ipc_pset.c:625kn->kn_fp = entry->ie_fp;kn->kn_hook = pset; and retain the reference taken above rather than releasing it. Do not write an fp* into the union slot — with f_isfd = 0 nothing will fdrop it.
ipc_pset.c:651 (detach)reaches the pset via kn_fp->f_data->ie_objectpset = kn->kn_hook;, then knlist_remove if still listed, then release the reference on every exit path.
ipc_pset.c:666 (event)entry = kn->kn_fp->f_data; — unconditional, before the EV_EOF early-outseed pset from kn->kn_hook. Must be seeded, not NULL — see R2.
ipc_entry.c:606comment: “we deliberately skip closing the knote so that it will have the last reference to the fp”False after Stage 1. Pset destruction moves earlier; rewrite the comment.
ipc_entry.c:675 / :686knote_fdclose() reaps the knote on execAdd explicit teardown. knote_fdclose only walks kq_knlist[fd]; with f_isfd = 0 the knote lives in kq_knhash and can never be found.

libdispatch needs no changes — verified by sweep: no mach source uses _dispatch_unote_create_with_fd, and the muxnote bucket hash already special-cases EVFILT_MACHPORT. libmach needs no code change either; its ident is already a name, not an fd.

4.2 Prerequisite

ipc_pset_alloc_name never calls sx_init / knlist_init, unlike ipc_pset_alloc. A pset created that way has an uninitialised ips_note, and knlist_add would write into garbage. Latent today; live after Stage 1. Land the init first, on its own.

4.3 Risks

  1. R1 — reintroducing the PR #250 UAF, worse. Today ips_reference is released immediately after knlist_add, and the pset survives because kqueue’s fp reference pins it. Remove f_isfd and nothing holds it. The reference must become the knote’s, released in detach. Flipping the flag while leaving the reference as a matched pair moves the write-after-free from a narrow window to the whole knote lifetime — presenting as the same page fault write 0x70.
  2. R2 — non-recursive mutex panic. ipc_object_translate only takes io_lock when the caller did not pre-seed *objectp, and io_lock is MTX_DEF. The natural-looking edit (pset = IPS_NULL) panics on the later re-lock. Will not show in a smoke test — needs real message delivery.
  3. R3 — detach on a foreign space. kqueue_drain runs in whatever thread does the last fdrop of the kq, which for a passed kq is another process. Detach must use the cached pointer and must never re-resolve by name.
  4. R4 — pset destruction moves earlier, exposing three KNOTE(EV_EOF) sites in ipc_right.c that have never fired at live knotes before.
  5. R5 — Capsicum posture change. The kqueue path’s cap_event_rights is replaced by the lookup’s CAP_KQUEUE_EVENT|CAP_KQUEUE_CHANGE.

The whole set must land as one atomic commit. .f_isfd = 0 without the event fix is an immediate NULL deref; without the attach-side reference it is a UAF on the first pset destroy.

5. Stage 2 — names become table indices

Confirmed starting state: is_table is not a name-indexed table. It is a chained hash keyed on the object pointer, 7 buckets, and it never growsipc_entry_grow_table does not exist in this tree.

5.1 Sub-stages

C0 — prep, no semantic change

Resize the table progression for a pointer array; add a separate reverse-hash array and retarget ipc_hash.c to it; merge the duplicate ipc_entry_hash_delete; delete dead fd_last_used. is_table becomes allocated-but-unused.

C1 — locking discipline high risk

Make ipc_entry_lookup require the space lock. Today it takes none, and it ignores its space argument entirely, reading curthread’s fd table (ipc_entry.c:321) — so a task operating on another task’s space silently searches its own. Fixing that is a correctness win in its own right. Do this before the table, while lookups are still cheap. This is where a deadlock surfaces if one exists.

C2a — user-reference counts off f_count

ipc_entry_refs() returns entry->ie_fp->f_count (ipc_entry.c:619). A fresh entry has f_count == 1 from the fd-table slot, so urefs and fd references share a counter with different zero points — and the code disagrees with itself about which. Net effect: mach_port_deallocate on a send right takes the > 1 branch, never decrements ip_srights, and two calls are needed to release one right. fork() compounds it: fdcopy() holds every inherited Mach fp, adding +1 per fork per port. ~35 call sites in ipc_right.c, each needing individual review against XNU — not a mechanical substitution.

C2b — the pivot large, unavoidably

New ipc_entry_get / alloc / alloc_name / lookup / close / dealloc; a new ipc_entry_grow_table; the new name encoding; per-slot generations. The name encoding, the table, and the lookup have to change together — there is no way to split this further and keep the tree bootable. Compensate with test coverage, not artificial splitting.

C3 — remove the fd backing (requires Stage 1)

Delete ie_fp, mach_fileops, and ~150 lines of copied FreeBSD fd internals. Move mach_port_close’s teardown body somewhere first — do not lose it. Delete the implicit fileport path (§6). Add a per-space entry cap.

C4 — fork/exec semantics

Rewrite ipc_entry_list_close, preserving both out-of-tree guards (non-Mach and Linux-ABI processes — each a real crash fix). Decide fork inheritance and exec reset deliberately. This is where daemon-level regressions surface; budget a full bring-up.

C5 — userland

Fix libmach/include/mach/port.h, whose macros currently disagree with the kernel’s. Retire the launchd/src/runtime.c workaround that exists because MACH_PORT_INDEX always returns 0. Must land close in time to C2b, not months later — with the new encoding, leaving it in place would size a launchd table by raw name.

C6 — introspection restoration

Re-enable mach_port_names and mach_port_space_info, both currently disabled and both written assuming a name-indexed table. Recovers what §6 loses.

5.2 Name encoding: adopt Apple’s layout

#define	MACH_PORT_INDEX(name)		((name) >> 8)
#define	MACH_PORT_GEN(name)		(((name) & 0xff) << 24)
#define	MACH_PORT_MAKE(index, gen)	(((index) << 8) | ((gen) >> 24))

The reason the tree deviated — that an index must remain a valid fd — is exactly what Stage 2 removes. Userland already ships Apple’s shape and currently disagrees with the kernel; taking Apple’s layout reconciles them and retires the launchd workaround.

5.3 ipc_hash survives

The indexed table is name → entry; ipc_hash is object → name. Opposite directions; neither subsumes the other. Without it, receiving a second send right to a port you already hold would mint a new name instead of coalescing — and mach_task_self() would return a different name on every call, leaking an entry each time.

6. What else breaks

7. The ordering trap

Fixing the two name leaks before Stage 2 makes the system worse, not better. Those leaks — the double-fdalloc in ipc_entry_alloc_name and the f_count/uref off-by-one — are what currently push the fd band upward and drive the failure rate to zero after ~70–80 calls. Restore correct free semantics while names are still ungenerationed and the recycling band stays dense indefinitely: a transient startup wedge becomes a permanent one. It would look like the fix caused a catastrophic regression.

Fold both into Stage 2. Bug (a) is not independently fixable anyway — a correct alloc_name requires a real table.

8. Test plan

In-kernel tests via the existing sysctl-handler pattern:

Namespace-separation assertions: after boot procstat -f 1 shows no port fds; close(port_name) returns EBADF; an ordinary fd passed as a port name returns KERN_INVALID_NAME.

Acceptance: boot to launchd with syslogd, configd, notifyd and mDNSResponder running, plus a libdispatch workload driving EVFILT_MACHPORT — then the cold-boot wedge measurement (10 blocks × 10 sends) at 0/100. The pre-fix baseline is 35/100, and PR #144 measured 35/100.

9. What is not claimed

Stages 1 and 2 are expected to fix the wedge; that is not demonstrated. The root-cause analysis is verified by reading, but four predicted fixes during this investigation (#126, #129, #130, #144) each addressed a real defect and none moved the measured rate. Treat the outcome as unproven until the acceptance measurement says otherwise.

10. Open questions

  1. Does anything in userland use a port name as an fd (close, dup, SCM_RIGHTS)? libmach is clean; launchd, libdispatch, CoreFoundation, configd, syslogd and mDNSResponder were not audited. Needs a dedicated sweep before C3.
  2. Fork inheritance policy — who depends on the current accident? mach_task_fork_bsport suggests full inheritance was tried and panicked.
  3. Pointer array vs XNU’s flat array. A pointer array keeps entry addresses stable across growth, which matters because this tree passes ipc_entry_t across lock drops constantly. Flat-array fidelity would make C1 a correctness prerequisite rather than hygiene.
  4. Per-space entry cap value.
  5. Is ipc_right_lookup’s lookup-then-lock ordering deliberate (avoiding recursion) or accidental? C1 hinges on it.
  6. Does anything run Mach under cap_enter()? Stage 2 removes the only Capsicum check on ports.

11. Methodology note

Both scoping agents were run against a working tree that still had an experimental generation-counter patch applied. Both consequently reported that generations were already live and that EVFILT_MACHPORT was already broken for 63 of every 64 names. That is false for main and for the deployed kernel; it described the uncommitted patch. Each agent flagged the discrepancy in its own open questions, which is how it was caught.

Everything cited here has been re-verified against clean main, and line numbers re-derived. The agents’ findings in files not touched by that patch — ipc_pset.c, ipc_hash.c, ipc_object.c, ipc_right.c, ipc_table.c, task.c — are unaffected. Their independent derivation of the fget(ident) blocker, reached without knowing it had been predicted, is corroboration rather than contamination.

12. Tickets

Umbrella: nextbsd-kernel#145.

#StageNote
1461 prereqipc_pset_alloc_name init — land first, alone
1471EVFILT_MACHPORT resolves through ipc_space — atomic commit
1482 / C0reverse hash off is_table — no semantic change
1492 / C1lookup takes the space lock, and honours space
1502 / C2aurefs off f_count — do not land alone
1512 / C2bthe pivot — cannot be split further
1522 / C3delete the fd backing
1532 / C4fork/exec semantics — where daemon regressions surface
1542 / C5userland macros — must land near C2b
1552 / C6restore introspection
156verifytest harness; acceptance is 0/100 cold-boot
144relatedbare-port lost wakeup — real, but measured 35/100, identical to pre-fix