loader: select detach path by XNU build version

detach_golden_gate() (a 1:1 port of debugserver's MachProcess::Detach) is
correct only on new-enough XNU; on older kernels it leaves the tracee
P_LTRACED, forcing SIG_DFL on all signals and freezing signal-driven GUI
apps, so those need the classic detach(). Parse the xnu build number from
kern.version and route to detach_golden_gate() at >= 13432.0.94.501.4,
otherwise detach(). Comparison is component-wise numeric (a string compare
mis-orders "9" vs "13432" and the dotted tail); falls back to the classic
path on any sysctl/parse failure.

This is a temporary workaround: the threshold is empirical and the divergence
is not yet understood. Once Apple releases the corresponding XNU sources we
can properly investigate the root cause and, ideally, converge on a single
detach path that works across kernels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
LifeisawfulandClaude Opus 4.8 committed 2026-07-24 10:30:10 +02:00
1 parent cd411f741b
commit 92107acde0
1 file changed
+98 -1
+98 -1
View File
@@ -7,6 +7,7 @@
#include <sys/mman.h>
#include <sys/event.h>
#include <sys/ptrace.h>
#include <sys/sysctl.h>
#include <sys/wait.h>
#include <sched.h>
@@ -33,6 +34,55 @@ const char* logsEnabled = nullptr;
} \
} while (0)
// Returns true when the running XNU build is >= the given threshold build
// number, compared component-wise (numeric), parsed from kern.version's
// "root:xnu-<A.B.C.D.E>~<F>" token. On any parse/sysctl failure, returns
// `fallback` (default false => keep the conservative detach() path).
//
// A string compare would be wrong here ("9" > "13432" lexically, and the dotted
// tail needs numeric ordering), so we compare each dotted component as an int.
// The trailing "~N" revision suffix is ignored. Missing trailing components in
// the running build are treated as 0, so an exact prefix match counts as "at
// least" (>=).
static bool xnuBuildAtLeast(const int* threshold, size_t nThreshold, bool fallback = false) {
char buf[512];
size_t len = sizeof(buf);
if (sysctlbyname("kern.version", buf, &len, nullptr, 0) != 0) {
return fallback;
}
buf[sizeof(buf) - 1] = '\0';
const char* p = strstr(buf, "root:xnu-");
if (!p) {
return fallback;
}
p += strlen("root:xnu-");
for (size_t i = 0; i < nThreshold; ++i) {
// A component must start with a digit; anything else (end of token,
// "~", "/") means the running build has no more components -> treat as 0.
int running = 0;
if (*p >= '0' && *p <= '9') {
char* end = nullptr;
long v = strtol(p, &end, 10);
running = (int)v;
p = end;
if (*p == '.') {
++p; // step over the separator to the next component
}
}
if (running != threshold[i]) {
return running > threshold[i];
}
}
// All compared components equal -> at least the threshold.
return true;
}
// XNU build 13432.0.94.501.4~1 — first kernel where the debugserver-style
// detach (detach_golden_gate) is correct; older kernels need detach().
static const int kGoldenGateXnuBuild[] = {13432, 0, 94, 501, 4};
typedef const struct dyld_process_info_base* DyldProcessInfo;
extern "C" DyldProcessInfo _dyld_process_info_create(task_t task, uint64_t timestamp,
@@ -211,6 +261,39 @@ public:
}
bool detach() {
// PT_DETACH requires the tracee to be in a BSD signal-stop. Under
// PT_ATTACHEXC our stops arrive as Mach exceptions (the BRK is a bare
// EXC_BREAKPOINT carrying no signal), which PT_DETACH rejects with
// EBUSY. So drive the tracee into a SIGSTOP job-control stop and hold
// it — the approach debugserver takes. SIGSTOP can't be caught or
// ignored, and PT_DETACH only accepts a held signal-stop like this one
// (a bare EXC_BREAKPOINT or a running process both give EBUSY).
kill(childPid_, SIGSTOP);
if (!exc_.reply(0)) { // release the held stop; run into the SIGSTOP
return false;
}
if (!waitForStopped(SIGSTOP)) {
fprintf(stderr, "detach: failed to reach SIGSTOP stop\n");
return false;
}
// Restore the task's exception ports, PT_DETACH from the held SIGSTOP
// stop, then release the exception (unblocking the thread). Order
// matters: PT_DETACH must run while the SIGSTOP exception is still held,
// and the release must run after (ptrace is no longer valid post-detach).
exc_.restoreAndTearDown();
bool ok = true;
if (ptrace(PT_DETACH, childPid_, (caddr_t)1, 0) < 0) {
perror("ptrace(PT_DETACH)");
ok = false;
}
exc_.release();
if (ok) {
LOG("Debugger detached.\n");
}
return ok;
}
bool detach_golden_gate() {
// Exact 1:1 port of lldb debugserver's MachProcess::Detach()
// (llvm lldb/tools/debugserver/source/MacOSX/MachProcess.mm). Order:
//
@@ -1154,7 +1237,21 @@ int main(int argc, char* argv[]) {
// replace the exports in X19 register with the address of the mapped macho
dbg.setRegister(MuhDebugger::Register::X19, machoExportsAddress);
dbg.detach();
// Pick the detach path by kernel version. detach_golden_gate() (a 1:1 port
// of debugserver's MachProcess::Detach) is correct only on new-enough XNU;
// on older kernels it leaves the tracee P_LTRACED (forces SIG_DFL on all
// signals, freezing signal-driven GUI apps), so those use detach().
bool useGoldenGate = xnuBuildAtLeast(
kGoldenGateXnuBuild, sizeof(kGoldenGateXnuBuild) / sizeof(kGoldenGateXnuBuild[0]));
if (useGoldenGate) {
LOG("Using detach_golden_gate (xnu >= 13432.0.94.501.4)\n");
dbg.detach_golden_gate();
} else {
LOG("Using detach (xnu < 13432.0.94.501.4)\n");
dbg.detach();
}
// Block until the parent (wine) exits. We can't use waitpid since
// the parent is not our child, so use kqueue with EVFILT_PROC.