From f35feaec3e93174228dafc6f184fd55cee64d596 Mon Sep 17 00:00:00 2001 From: Niclas Overby Date: Fri, 21 Aug 2026 05:17:35 +0200 Subject: [PATCH] fix(cider): the caller owns an xpc pipe reply, and MoneyMoney opens with launchd (#160) ONE RELEASE WAS THE WHOLE THING. +[deserializer process:] returns an AUTORELEASED dictionary, and the three pipe entry points that hand a dictionary out through an out-parameter (xpc_pipe_routine, xpc_pipe_receive, xpc_pipe_try_receive) passed that reference straight to the caller. Every caller of those owns what it is handed and releases it, and libinfo does exactly that around every membership lookup. So the reply was released twice, once by the caller and once when the pool drained. WHAT IT COST: trustd answered exactly one trust evaluation and then died draining the message handler's pool, CRASHTRACE signal=11 addr=0x20 in objc_release, on an object that was already gone. StaticCode.cpp evaluates trust at most twice, so the application's second evaluation had nothing to answer it and MoneyMoney sat on Starting MoneyMoney forever. HOW IT WAS FOUND, with the instrument built for it last rung. The drain trace names the dying object but not its origin; CIDER_TRACE_POOL=sites names every autorelease with the caller resolved through dladdr. The pool is LIFO, so the object that crashes the drain is the one autoreleased just before the last one printed: CIDER_POOL autorelease 0x7ef840206450 isa=0x7ef851221620 from -[OS_xpc_pipe sendMessage:withSynchronousReply:flags:] CIDER_POOL releasing 0x7ef8402062b0 ... __NSCFString <- last line before the crash and that isa resolves to OS_xpc_dictionary by sliding libxpc's own class table against the OS_xpc_serializer isa the same log prints. That is the reply. AFTER, all measured in the same driver: * zero trustd crashes in five runs, where it used to die in about three of five * trustd serves six trust evaluations instead of one * the static validation runs to completion: staticValidateCore back, resource scan finished with collected status=0, validateResources back * MoneyMoney opens its main window with launchd running, and the File menu opens on a click with its items, separators and key equivalents. Looked at. * nine of nine test suites still green, and Swift Publisher unchanged It also closes two older guesses. trustd does not go deaf after its first message, and no change to how launchd learns that a MachService job exited was needed for this: the daemon was being killed by a use-after-free of our own making. --- docs/wayland-port.md | 39 ++++++++++ ...08-the-caller-owns-an-xpc-pipe-reply.patch | 75 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 vendor/patches/libxpc/0008-the-caller-owns-an-xpc-pipe-reply.patch diff --git a/docs/wayland-port.md b/docs/wayland-port.md index 7728372ae..0f8bbf772 100644 --- a/docs/wayland-port.md +++ b/docs/wayland-port.md @@ -14400,3 +14400,42 @@ that path on the host. Two independent defects, both about which container the g sweep (the exe says it is a guest runtime binary, the command line says which prefix). Nine of nine, twice in a row, against the new daemon. + +## MoneyMoney starts with launchd: the reply that was released twice (2026-08-21) + +One release. `+[deserializer process:]` returns an **autoreleased** dictionary, and the three pipe +entry points that hand a dictionary out through an out-parameter (`xpc_pipe_routine`, +`xpc_pipe_receive`, `xpc_pipe_try_receive`) passed that reference straight to the caller. Every caller +of those owns what it is handed and releases it: libinfo does exactly that around every membership +lookup. So the object was released twice, once by the caller and once when the pool drained, and from +then on that memory was somebody else's. + +**How it was found**, and it took the instrument built for it. The drain trace named the dying object +but not its origin; `CIDER_TRACE_POOL=sites` names every autorelease with the caller resolved through +`dladdr`. The pool is LIFO, so the object that crashed the drain is the one autoreleased just before +the last one printed: + + CIDER_POOL autorelease 0x7ef840206450 isa=0x7ef851221620 from -[OS_xpc_pipe sendMessage:withSynchronousReply:flags:] + CIDER_POOL releasing 0x7ef8402062b0 ... __NSCFString <- last line before the crash + +and the isa resolves through libxpc's own class table (slide from `OS_xpc_serializer`, whose address +the same log prints) to `OS_xpc_dictionary`. That is the reply. + +**What it cost, before the fix:** trustd answered exactly one trust evaluation and then died draining +the message handler's pool (`CRASHTRACE signal=11 addr=0x20` in `objc_release`). `StaticCode.cpp` +evaluates trust at most twice, so the application's second evaluation had nothing to answer it. + +**After:** zero crashes in five runs, trustd serves **six** evaluations instead of one, the static +validation runs to completion, + + CIDER_CSSTEP staticValidateCore back + CIDER_CSSTEP resource scan finished, collected status=0 + CIDER_CSSTEP validateResources back + +and **MoneyMoney opens its main window with launchd running**: menu bar, toolbar, sidebar, trial +banner, and the File menu opens on a click with its items, separators and key equivalents. Looked at. +Nine of nine test suites still green, and Swift Publisher unchanged. + +That also closes the older note that trustd "goes deaf after its first message" and the guess that a +MachService job needed to be re-demand-started for this to work. Neither was the cause: the daemon was +being killed by a use-after-free that our own ownership mistake handed it. diff --git a/vendor/patches/libxpc/0008-the-caller-owns-an-xpc-pipe-reply.patch b/vendor/patches/libxpc/0008-the-caller-owns-an-xpc-pipe-reply.patch new file mode 100644 index 000000000..f3c0b2136 --- /dev/null +++ b/vendor/patches/libxpc/0008-the-caller-owns-an-xpc-pipe-reply.patch @@ -0,0 +1,75 @@ +The caller owns an xpc pipe reply, so it has to leave retained. + +THIS IS WHY MONEYMONEY COULD NOT START WITH LAUNCHD RUNNING, and the whole chain is one release. + ++[deserializer process:] returns an AUTORELEASED dictionary. The three pipe entry points that hand a +dictionary out through an out-parameter (xpc_pipe_routine, xpc_pipe_receive, xpc_pipe_try_receive) +passed that autoreleased reference straight to the caller, and every caller of those releases what it +is handed: libinfo does exactly that around every membership lookup (ds_module.c releases the reply, +membership.c releases the reply). So the object was released twice, once by the caller and once when +the pool drained. + +WHAT THAT DID, measured end to end: + + CIDER_TRUSTD trust evaluate: reply sent + CIDER_TRUSTD handler returning + cider CRASHTRACE signal=11 code=1 addr=0x20 ... objc_release + 37 + +trustd answered one trust evaluation and then died draining the message handler's pool, on an object +that was already gone. CIDER_TRACE_POOL=sites named it: the pointer the crash reports in rdi was +autoreleased "from -[OS_xpc_pipe sendMessage:withSynchronousReply:flags:]", and the class table in +libxpc says its isa is OS_xpc_dictionary. The application's SECOND trust evaluation (StaticCode.cpp +evaluates at most twice) then had nothing to answer it, and MoneyMoney sat on Starting MoneyMoney +forever. + +AFTER: zero crashes in five runs, trustd serves six evaluations instead of one, the static validation +runs to completion (staticValidateCore back, resource scan finished, collected status=0), and the +application opens its main window with launchd running for the first time. Nine of nine test suites +still green. +--- a/src/pipe.m ++++ b/src/pipe.m +@@ -244,7 +244,23 @@ + goto out; + } + +- *incomingMessage = dict; ++ /* ++ * THE CALLER OWNS THE REPLY, so it has to leave here retained. ++ * ++ * +[deserializer process:] returns an AUTORELEASED dictionary, and every caller of ++ * xpc_pipe_routine / xpc_pipe_receive / xpc_pipe_try_receive releases what it is handed ++ * (libinfo does exactly that around every membership lookup). Handing out the ++ * autoreleased reference means the object is released twice: once by the caller and ++ * once when the pool drains. ++ * ++ * That is what killed trustd. It answered one trust evaluation, and the drain of the ++ * message handler's pool then walked into an object that was already gone: CRASHTRACE ++ * signal=11 in objc_release, and CIDER_TRACE_POOL=sites named the pointer and its ++ * origin, "from -[OS_xpc_pipe sendMessage:withSynchronousReply:flags:]", an ++ * OS_xpc_dictionary. The client's SECOND evaluation then had nothing to answer it, and ++ * MoneyMoney sat on its splash. ++ */ ++ *incomingMessage = [dict retain]; + + status = 0; + } else { +@@ -284,7 +300,8 @@ + goto out; + } + +- *incomingMessage = dict; ++ /* Owned by the caller, as in receiveWithPort above. */ ++ *incomingMessage = [dict retain]; + *replyPort = header->msgh_local_port; + + status = 0; +@@ -630,7 +647,8 @@ + goto out; + } + +- *reply = dict; ++ /* Owned by the caller, as in receiveWithPort above. */ ++ *reply = [dict retain]; + + status = 0; + } else { -- 2.51.2