[PR #5916] [client] Fix WGIface.Close deadlock when DNS filter hook re-enters GetDevice #27138

Open
opened 2026-08-05 07:08:15 -04:00 by saavagebueno · 0 comments
Owner

Original Pull Request: https://github.com/netbirdio/netbird/pull/5916

State: closed
Merged: Yes


Describe your changes

WGIface.Close() takes w.mu and holds it across w.tun.Close(). The underlying wireguard-go device waits for its send/receive goroutines to drain before returning, and some of those goroutines re-enter WGIface during shutdown.

Specifically, the userspace packet filter DNS hook in client/internal/dns.ServiceViaMemory.filterDNSTraffic calls s.wgInterface.GetDevice() on every packet — which also needs w.mu. With Close holding the mutex, the read goroutine blocks in GetDevice, and Close waits forever for that goroutine to exit.

Fix: release w.mu before calling w.tun.Close(). The remaining steps in Close (waitUntilRemoved, Destroy) only call w.Name(), which reads w.tun.DeviceName() lock-free, so they don't need the mutex either.

Stack trace from a failing CI run:

goroutine 1352 [sync.WaitGroup.Wait, 4 minutes]:
  WGIface.Close  -> holds w.mu
  TunDevice.Close
  wireguard.Device.Close  -> WaitGroup.Wait (blocked)
  TestDNSPermanent_updateUpstream

goroutine 1367 [sync.Mutex.Lock, 4 minutes]:
  WGIface.GetDevice  -> blocked on w.mu
  ServiceViaMemory.filterDNSTraffic.func1
  udpHooksDrop
  filterOutbound
  FilteredDevice.Read
  wireguard.Device.RoutineReadFromTUN

This surfaces as a 5-minute timeout on the macOS Client / Unit CI job:

panic: test timed out after 5m0s
  running tests:
    TestDNSPermanent_updateUpstream (4m54s)

Seen e.g. in #5807 and earlier PRs that trigger the macOS runner — the failure is unrelated to those PRs' code paths.

No tracking issue; discovered while triaging the flaky macOS CI job referenced above.

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Internal concurrency fix in client/iface. The public WGIface API surface and its behavior are unchanged; callers see the same methods with the same contract. No user-visible configuration or workflow changes.

Docs PR URL (required if "docs added" is checked)

N/A

Test

New client/iface/iface_close_test.go reproduces the deadlock with a fake WGTunDevice whose Close() blocks on a channel — simulating the wireguard-go goroutine drain. A parallel GetDevice() call would block forever under the old code; with the fix it returns immediately.

  • Without the fix: FAIL: TestWGIface_CloseReleasesMutexBeforeTunClose — GetDevice() deadlocked while WGIface.Close was closing the tun
  • With the fix: 20× go test -race -count=20 clean.

The test does not need CAP_NET_ADMIN / /dev/net/tun, so it runs on all CI environments including sandboxed containers.

**Original Pull Request:** https://github.com/netbirdio/netbird/pull/5916 **State:** closed **Merged:** Yes --- ## Describe your changes `WGIface.Close()` takes `w.mu` and holds it across `w.tun.Close()`. The underlying wireguard-go device waits for its send/receive goroutines to drain before returning, and some of those goroutines re-enter `WGIface` during shutdown. Specifically, the userspace packet filter DNS hook in `client/internal/dns.ServiceViaMemory.filterDNSTraffic` calls `s.wgInterface.GetDevice()` on every packet — which also needs `w.mu`. With `Close` holding the mutex, the read goroutine blocks in `GetDevice`, and `Close` waits forever for that goroutine to exit. Fix: release `w.mu` before calling `w.tun.Close()`. The remaining steps in `Close` (`waitUntilRemoved`, `Destroy`) only call `w.Name()`, which reads `w.tun.DeviceName()` lock-free, so they don't need the mutex either. Stack trace from a failing CI run: ``` goroutine 1352 [sync.WaitGroup.Wait, 4 minutes]: WGIface.Close -> holds w.mu TunDevice.Close wireguard.Device.Close -> WaitGroup.Wait (blocked) TestDNSPermanent_updateUpstream goroutine 1367 [sync.Mutex.Lock, 4 minutes]: WGIface.GetDevice -> blocked on w.mu ServiceViaMemory.filterDNSTraffic.func1 udpHooksDrop filterOutbound FilteredDevice.Read wireguard.Device.RoutineReadFromTUN ``` This surfaces as a 5-minute timeout on the macOS **Client / Unit** CI job: ``` panic: test timed out after 5m0s running tests: TestDNSPermanent_updateUpstream (4m54s) ``` Seen e.g. in #5807 and earlier PRs that trigger the macOS runner — the failure is unrelated to those PRs' code paths. ## Issue ticket number and link No tracking issue; discovered while triaging the flaky macOS CI job referenced above. ## Stack <!-- branch-stack --> ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal concurrency fix in `client/iface`. The public WGIface API surface and its behavior are unchanged; callers see the same methods with the same contract. No user-visible configuration or workflow changes. ### Docs PR URL (required if "docs added" is checked) N/A ## Test New `client/iface/iface_close_test.go` reproduces the deadlock with a fake `WGTunDevice` whose `Close()` blocks on a channel — simulating the wireguard-go goroutine drain. A parallel `GetDevice()` call would block forever under the old code; with the fix it returns immediately. - Without the fix: `FAIL: TestWGIface_CloseReleasesMutexBeforeTunClose — GetDevice() deadlocked while WGIface.Close was closing the tun` - With the fix: 20× `go test -race -count=20` clean. The test does not need `CAP_NET_ADMIN` / `/dev/net/tun`, so it runs on all CI environments including sandboxed containers.
saavagebueno added the pull-request label 2026-08-05 07:08:15 -04:00
Sign in to join this conversation.
No Label pull-request
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#27138