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 {