Skip to content

TUN inbound (Darwin): Wait() blocks via kqueue instead of busy-spinning - #6580

Open
Jidos86 wants to merge 4 commits into
XTLS:mainfrom
Jidos86:fix-darwin-tun-wait-busy-spin
Open

TUN inbound (Darwin): Wait() blocks via kqueue instead of busy-spinning#6580
Jidos86 wants to merge 4 commits into
XTLS:mainfrom
Jidos86:fix-darwin-tun-wait-busy-spin

Conversation

@Jidos86

@Jidos86 Jidos86 commented Aug 2, 2026

Copy link
Copy Markdown

Fixes #6579.

What

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.

For comparison, tun_android.go builds its link endpoint via gVisor's own fdbased.New(...) -- a properly blocking fd-based endpoint -- and never had this issue.

Impact

Observed causing severe, sustained device heating on a real iPhone 16 Pro during active tunnel connections -- severe enough that iOS's own thermal management disabled the camera flash ("iPhone needs to cool down before using flash"). See #6579 for the full writeup.

The fix

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 a bounded time.Sleep(1ms) if kqueue setup ever fails, or once the kqueue reports a persistent (non-EINTR) kevent failure at runtime, so this can only make things better or leave them unchanged, never worse.

Testing

  • Verified locally that the patch applies cleanly at this commit, and that the patched proxy/tun package (and the whole dependent libXray module) cross-compiles successfully for GOOS=darwin GOARCH=arm64 and GOOS=ios GOARCH=arm64.
  • Verified on a real iPhone 16 Pro: with only this fix applied (no other functional changes), a 10-minute active tunnel connection showed no measurable temperature increase, versus severe heating within a similar timeframe before the fix.
  • Added proxy/tun/tun_darwin_test.go coverage (via unix.Socketpair, no real utun/root needed) for: blocking with no data, waking on a readable fd, timeout, Close() waking a blocked wait, concurrent/repeated close, and a persistent kevent failure not causing a spin.

Update (review round 1)

Addressed both correctness gaps @yiguodev flagged:

  • fd lifecycle: waitKq is now a dedicated waitKqueue type (atomic closed flag + sync.Once) instead of a bare int, so Close() and a racing Wait() can no longer double-close or operate on a since-reused fd number.
  • Runtime kevent errors: no longer discarded. A persistent (non-EINTR) failure now permanently switches Wait() to the time.Sleep fallback instead of retrying the same broken kqueue forever, which would have silently reintroduced the exact busy-spin this PR removes.

Note

This was found and fixed with the help of Claude (Anthropic's AI coding assistant), while debugging a real thermal-management report on iOS. I'm not a gVisor internals expert -- there may well be a more elegant fix (maybe even adapting fdbased for Darwin the way Android uses it, if the fd differences allow it). I'm just sharing what fixed the issue in my own testing, not claiming this is the right solution -- happy for a maintainer to take a completely different approach.

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 XTLS#6579
@Fangliding

Copy link
Copy Markdown
Member

procyield 这里大抵是被滥用了 该删掉的

Jidos86 and others added 2 commits August 3, 2026 18:47
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.
…usy-spin

# Conflicts:
#	proxy/tun/tun_darwin.go
@RPRX

RPRX commented Aug 12, 2026

Copy link
Copy Markdown
Member

@yiguodev @iambabyninja

@yiguodev

Copy link
Copy Markdown
Collaborator

AI review opinion (OpenAI Codex)

This is an AI-generated technical assessment of the current PR head (98d67565). It is not a maintainer decision.

Overall, the reported problem is real and the main direction is sound: replacing procyield(1) with a level-triggered kqueue/EVFILT_READ wait removes the empty-queue busy loop without introducing a read/readiness race. The real-device thermal result is also valuable. However, I recommend requesting changes before merge because the new raw-fd lifecycle still has two correctness gaps.

Blocking findings

  1. [P1] waitKq can be closed twice or reused while Wait() races with shutdown

    Close() calls unix.Close(t.waitKq) but does not guard the close, invalidate the stored integer, or synchronize it with Wait(). If shutdown is repeated or concurrent and Darwin reuses that fd number, a later Close() can close an unrelated descriptor. A racing Wait() can likewise call kevent on a closed/reused fd. I reproduced the double-close/reused-fd case with a focused Darwin test.

    Please make ownership and closure idempotent and synchronize Wait() with Close() (for example, a dedicated waiter/resource object plus locking or an explicit waiter join), rather than only adding sync.Once without addressing the use/close race.

  2. [P2] Runtime kevent errors are ignored and can recreate the busy loop

    The setup-failure path sleeps, but _, _ = unix.Kevent(...) discards runtime errors. A persistent EBADF, EINVAL, or similar failure makes Wait() return immediately, so dispatchLoop resumes the same ReadPacket()/Wait() spin this PR is intended to remove. Handle EINTR and shutdown explicitly; for terminal errors, disable the failed waiter and use a bounded sleep or propagate a terminal condition.

Missing coverage

The existing CI is green, and I independently verified go test ./proxy/tun, go vet ./proxy/tun, plus Darwin/arm64 and iOS/arm64 compilation. Those checks do not exercise the new lifecycle. Please add Darwin socketpair/pipe tests covering:

  • no-data blocking;
  • readable-fd wake-up;
  • timeout;
  • Close() waking a blocked wait;
  • repeated/concurrent close safety;
  • runtime kevent failure without spinning.

Minor documentation issue: the PR body still says setup failure falls back to procyield, while the current head uses time.Sleep(1ms).

After the fd lifecycle, error handling, and tests are addressed, I think this is a good fix and should be mergeable.

@yiguodev

Copy link
Copy Markdown
Collaborator

AI 审查意见(OpenAI Codex)

以下是 AI 基于当前 PR head(98d67565)生成的技术评估,不代表维护者决定。

总体上,问题真实,主要修复方向也正确:用 level-triggered 的 kqueue/EVFILT_READ 替代 procyield(1),可以消除空队列 busy loop,同时不会引入“读取后、注册等待前错过事件”的竞态。真机温度对比也很有价值。但当前版本建议 Request changes 后再合并,因为新增 raw fd 的生命周期还有两个正确性问题。

阻塞问题

  1. [P1] waitKq 可能被重复关闭,或在 Wait() 与关闭并发时发生 fd 重用问题

    Close() 直接调用 unix.Close(t.waitKq),但没有保证只关闭一次、没有将保存的整数失效,也没有与 Wait() 同步。如果发生重复或并发关闭,而 Darwin 已经复用了这个 fd 数字,后续 Close() 可能关闭无关资源;并发的 Wait() 也可能对已经关闭或复用的 fd 调用 kevent。我用一个针对性的 Darwin 测试复现了“第一次关闭后 fd 被复用,第二次关闭误关复用 fd”的情况。

    建议把等待器封装为独立的资源对象,令所有权与关闭具备幂等性,并同步 Wait()Close()(例如使用锁或明确等待 goroutine 退出)。仅增加 sync.Once 仍不足以解决使用与关闭之间的竞态。

  2. [P2] 运行期 kevent 错误被忽略,可能重新产生 busy loop

    当前只在 kqueue 创建失败时回退到 sleep,但 _, _ = unix.Kevent(...) 丢弃了实际等待产生的错误。若持续返回 EBADFEINVAL 等错误,Wait() 会立即返回,dispatchLoop 将重新高速执行 ReadPacket()/Wait(),恢复本 PR 要消除的空转。建议区分 EINTR、正常关闭和终止性错误;遇到终止性错误时禁用失效 waiter,并执行有界 sleep 或向上返回终止状态。

缺少的测试

现有 CI 全部通过;我也独立验证了 go test ./proxy/tungo vet ./proxy/tun,以及 Darwin/arm64、iOS/arm64 编译。但这些检查没有覆盖新增的生命周期。建议增加 Darwin socketpair/pipe 测试,覆盖:

  • 无数据时真正阻塞;
  • fd 可读后及时唤醒;
  • 超时返回;
  • Close() 唤醒阻塞等待;
  • 重复/并发关闭安全;
  • 运行期 kevent 失败不会重新忙循环。

另有一个较小的文档问题:PR 描述仍写着创建失败后回退到 procyield,但当前代码已经改成 time.Sleep(1ms)

完成 fd 生命周期、错误处理和测试后,我认为这会是一个值得合并的修复。

…t kevent errors, add tests

- waitKq is now a dedicated waitKqueue type (atomic closed flag + sync.Once)
  instead of a bare int fd -- Close() and a racing Wait() could otherwise
  double-close or operate on a since-reused fd number (P1).
- wait() reports persistent kevent failures (anything but EINTR) as false;
  Wait() permanently falls back to the sleep path on that signal instead of
  retrying a syscall that's already shown it won't succeed, which would
  silently reintroduce the exact busy-spin this change removes (P2).
- Added proxy/tun/tun_darwin_test.go coverage for all six scenarios
  requested: blocking with no data, wake on readable fd, timeout, Close()
  waking a blocked wait, concurrent/repeated close, and persistent kevent
  failure without spinning -- via unix.Socketpair, no real utun/root needed.
@Jidos86

Jidos86 commented Aug 16, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review -- both P1 and P2 were real gaps, appreciate the specific repro on the double-close/fd-reuse case.

Pushed a fix (69d7bb2):

  • P1: replaced the bare int fd with a small waitKqueue type carrying an atomic.Bool closed flag (checked before every kevent call) plus sync.Once around the actual close -- so Wait() never enters the syscall on a fd number Close() has already invalidated (and possibly handed to an unrelated concurrent open()), and repeated/concurrent Close() calls are safe.
  • P2: wait() now returns false for anything but a benign EINTR. Wait() treats false as "this kqueue is dead" -- closes it and permanently falls back to time.Sleep(1ms) for the rest of that DarwinTun's lifetime, rather than re-entering a syscall that's already shown it won't succeed.
  • Added proxy/tun/tun_darwin_test.go covering the six scenarios you listed, via unix.Socketpair (no real utun/root needed): blocking with no data, wake-on-readable, timeout, Close() waking a blocked wait, concurrent/repeated close, and persistent kevent failure not spinning.
  • Fixed the stale procyield mention in the PR description.

Also re-verified go build/go vet/go test -c for darwin/arm64 and ios/arm64 against the updated code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUN inbound (Darwin): Wait() busy-spins instead of blocking, causing continuous high CPU / severe device heating on iOS

4 participants