From b7f98ae3fb38cbe04b7259e3d35f094e37bb7251 Mon Sep 17 00:00:00 2001 From: Jidos86 Date: Sun, 2 Aug 2026 22:43:22 +0500 Subject: [PATCH 1/2] TUN inbound (Darwin): Wait() blocks via kqueue instead of busy-spinning DarwinTun.Wait() was procyield(1) -- a CPU-yield hint, not a real blocking wait. stack_gvisor_endpoint.go's dispatchLoop (a single dedicated goroutine) calls ReadPacket() then Wait() in a tight loop with no other throttling whenever the tun's non-blocking fd has nothing to read, so this pinned a full CPU core for the entire connected lifetime of the tunnel, independent of actual traffic volume -- observed causing severe, sustained device heating on a real iPhone 16 Pro (severe enough that iOS's own thermal management disabled the camera flash). tun_android.go builds its link endpoint via gVisor's own fdbased.New(...) -- a properly blocking fd-based endpoint -- and never had this issue. This adds a kqueue registered for EVFILT_READ on the tun fd, and has Wait() genuinely block on it (1s bounded timeout, so a racing Close() stays responsive) instead of yielding and immediately re-looping. Falls back to the original procyield behavior if kqueue setup ever fails, so this can only make things better or leave them unchanged, never worse. Verified locally: applies cleanly at this commit, and the patched package (proxy/tun) plus the whole dependent libXray module cross-compile successfully for GOOS=darwin GOARCH=arm64. Not a gVisor-internals expert -- there may be a more elegant fix (perhaps fdbased could be adapted for Darwin the way Android uses it, if the fd differences allow it). This is what fixed the issue in real-device testing; a maintainer may well prefer a different approach. Found and fixed with the help of Claude (Anthropic's AI coding assistant). Fixes #6579 --- proxy/tun/tun_darwin.go | 50 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/proxy/tun/tun_darwin.go b/proxy/tun/tun_darwin.go index fdaafd3e1975..cc09b9157c1c 100644 --- a/proxy/tun/tun_darwin.go +++ b/proxy/tun/tun_darwin.go @@ -11,6 +11,7 @@ import ( "os" "strconv" "sync" + "time" "unsafe" "github.com/xtls/xray-core/common/buf" @@ -47,11 +48,42 @@ type DarwinTun struct { tunFd int ownsFd bool // true for macOS (we created the fd), false for iOS (fd from system) + // kqueue fd used by Wait() to genuinely sleep until tunFd is readable, + // instead of the previous procyield-only busy-spin (dispatchLoop in + // stack_gvisor_endpoint.go calls ReadPacket() then Wait() in a tight + // loop with no other throttling whenever the queue is empty -- with + // only procyield(1), that pins a full CPU core for as long as the + // tunnel is up, observed causing severe device heating/thermal + // shutdown). -1 if kqueue setup failed, in which case Wait() falls + // back to the original procyield behavior. + waitKq int + routeMonitor *os.File routeMonitorOnce sync.Once systemRoutes []netip.Prefix } +// newWaitKqueue creates a kqueue registered for read-readiness on fd, for +// Wait() to block on. Returns -1 (never a valid fd) if anything fails, so +// callers can fall back to procyield rather than error out of NewTun over +// what is purely a CPU-efficiency concern. +func newWaitKqueue(fd int) int { + kq, err := unix.Kqueue() + if err != nil { + return -1 + } + _, err = unix.Kevent(kq, []unix.Kevent_t{{ + Ident: uint64(fd), + Filter: unix.EVFILT_READ, + Flags: unix.EV_ADD | unix.EV_ENABLE, + }}, nil, nil) + if err != nil { + _ = unix.Close(kq) + return -1 + } + return kq +} + var ( _ Tun = (*DarwinTun)(nil) _ GVisorDevice = (*DarwinTun)(nil) @@ -76,6 +108,7 @@ func NewTun(options *Config) (Tun, error) { options: options, tunFd: fd, ownsFd: false, + waitKq: newWaitKqueue(fd), }, nil } @@ -96,6 +129,7 @@ func NewTun(options *Config) (Tun, error) { options: options, tunFd: int(tunFile.Fd()), ownsFd: true, + waitKq: newWaitKqueue(int(tunFile.Fd())), }, nil } @@ -126,6 +160,9 @@ func (t *DarwinTun) Close() error { _ = t.routeMonitor.Close() } }) + if t.waitKq >= 0 { + _ = unix.Close(t.waitKq) + } routeErr := t.unsetSystemRoutes() if t.ownsFd { return xerrors.Combine(routeErr, t.tunFile.Close()) @@ -234,9 +271,18 @@ func (t *DarwinTun) ReadPacket() (byte, *stack.PacketBuffer, error) { }), nil } -// Wait some cpu cycles +// Wait blocks until tunFd is readable (or a short timeout elapses), rather +// than spinning the CPU -- see the waitKq field's own doc comment. A bounded +// timeout (not an indefinite wait) keeps this responsive to a Close() that +// happens to race a call already parked here. func (t *DarwinTun) Wait() { - procyield(1) + if t.waitKq < 0 { + procyield(1) + return + } + events := make([]unix.Kevent_t, 1) + timeout := unix.NsecToTimespec((1 * time.Second).Nanoseconds()) + _, _ = unix.Kevent(t.waitKq, nil, events, &timeout) } func (t *DarwinTun) newEndpoint() (stack.LinkEndpoint, error) { From 95e88dd5557e3b4025c5a7c54fb4780af4e233c0 Mon Sep 17 00:00:00 2001 From: Jidos86 Date: Mon, 3 Aug 2026 18:47:33 +0500 Subject: [PATCH 2/2] Wait(): drop the procyield fallback entirely, use a real bounded sleep Reviewer feedback (Fangliding): the kqueue-setup-failure fallback still called procyield(1) -- the exact busy-spin this whole change exists to remove, just gated behind an edge case (kqueue setup failing, which practically never happens on a real Darwin system) instead of always. A genuine time.Sleep actually yields the CPU for a bounded duration, unlike procyield's near-instant scheduler hint, which would let the tight dispatchLoop caller (stack_gvisor_endpoint.go) spin just as hot as before if this path were ever actually hit. The now-unused //go:linkname procyield declaration is removed too rather than left as dead code. Verified via local cross-compile (darwin/arm64 and ios/arm64) -- go build/go vet both clean. --- proxy/tun/tun_darwin.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/proxy/tun/tun_darwin.go b/proxy/tun/tun_darwin.go index cc09b9157c1c..1a7333a30922 100644 --- a/proxy/tun/tun_darwin.go +++ b/proxy/tun/tun_darwin.go @@ -39,9 +39,6 @@ const ( ND6_INFINITE_LIFETIME = 0xFFFFFFFF // netinet6/nd6.h ) -//go:linkname procyield runtime.procyield -func procyield(cycles uint32) - type DarwinTun struct { tunFile *os.File options *Config @@ -55,7 +52,7 @@ type DarwinTun struct { // only procyield(1), that pins a full CPU core for as long as the // tunnel is up, observed causing severe device heating/thermal // shutdown). -1 if kqueue setup failed, in which case Wait() falls - // back to the original procyield behavior. + // back to a bounded time.Sleep instead. waitKq int routeMonitor *os.File @@ -65,8 +62,8 @@ type DarwinTun struct { // newWaitKqueue creates a kqueue registered for read-readiness on fd, for // Wait() to block on. Returns -1 (never a valid fd) if anything fails, so -// callers can fall back to procyield rather than error out of NewTun over -// what is purely a CPU-efficiency concern. +// callers can fall back to a bounded sleep rather than error out of NewTun +// over what is purely a CPU-efficiency concern. func newWaitKqueue(fd int) int { kq, err := unix.Kqueue() if err != nil { @@ -277,7 +274,14 @@ func (t *DarwinTun) ReadPacket() (byte, *stack.PacketBuffer, error) { // happens to race a call already parked here. func (t *DarwinTun) Wait() { if t.waitKq < 0 { - procyield(1) + // Reviewer feedback (XTLS/Xray-core#6580): procyield here is the + // same busy-spin this whole change exists to remove, just gated + // behind an edge case (kqueue setup failing, which practically + // never happens on real Darwin systems) instead of always -- a + // genuine bounded sleep actually yields the CPU instead of being a + // near-instant scheduler hint that lets the tight dispatchLoop + // caller (stack_gvisor_endpoint.go) spin just as hot as before. + time.Sleep(time.Millisecond) return } events := make([]unix.Kevent_t, 1)