Skip to content

[client] Fix WGWatcher silently failing to restart on fast disconnect/reconnect#6664

Open
riccardomanfrin wants to merge 4 commits into
mainfrom
wg_watcher_debounce
Open

[client] Fix WGWatcher silently failing to restart on fast disconnect/reconnect#6664
riccardomanfrin wants to merge 4 commits into
mainfrom
wg_watcher_debounce

Conversation

@riccardomanfrin

@riccardomanfrin riccardomanfrin commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Describe your changes

WGWatcher monitors WireGuard handshake liveness and tears down a dead transport so the guard reconnects. Fixes a race where the watcher could silently stop running, plus a related teardown race.

  1. Per-instance watcher (main fix). The watcher was a reused instance guarded by an enabled bool; disableWgWatcherIfNeeded only cancel()ed the goroutine, which cleared enabled after its loop returned (scheduler-dependent delay). On a fast disconnect + independent relay/ICE "ready", the re-enable saw a stale enabled == true and started nothing → a "connected" peer with no liveness watcher, never retried. Fix: create a fresh watcher per connection attempt, dropped on disable; lifecycle fully owned by Conn under conn.mu, so enable/disable can't race the old goroutine's async shutdown. Removed enabled/muEnabled.

  2. Recheck watcher ctx under conn.mu in onWGDisconnected. handshakeCheck runs lock-free; between its ctx check and acquiring conn.mu, a fast reconnect can slip in. onWGDisconnected checked only conn.ctx (engine-scoped), but disable cancels wgWatcherCtx — so a stale watcher could tear down the new connection on the old timeout (flap). Now it also rechecks watcherCtx.Err() under the lock.

  3. Log clarity. Distinguish "handshake not updated" from an actual timeout.

Deadlock constraint preserved: disableWgWatcherIfNeeded never waits for the goroutine (the timeout path reentrantly calls back under conn.mu); the cancelled goroutine drains via its own ctx.Done().

Verified: go test -run TestWGWatcher ./client/internal/peer/ passes, go build ./client/... clean.

Issue ticket number and link

No public GitHub issue — internal client-reliability fix for a scheduler-dependent race in the WireGuard liveness watcher. Pre-fix paths on main: disableWgWatcherIfNeeded, onWGDisconnected.

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)
  • This change does not modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — OR I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See CONTRIBUTING.md.

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 the client's WireGuard liveness watcher. No public CLI/API/gRPC/config surface changes; observable behavior is unchanged.

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

Paste the PR link from https://github.com/netbirdio/docs here:

N/A

Summary by CodeRabbit

  • Bug Fixes
    • Improved handshake watcher lifecycle during reconnects to reduce race conditions.
    • Ensured stale handshake timeout callbacks no longer affect an active connection.
    • Made watcher timeout handling more robust by checking for cancellation immediately after timer fires.
    • Adjusted handshake watch reset behavior to occur only when a watcher is present.
    • Refined “handshake not updated” warning messaging for clearer diagnostics.
  • Tests
    • Updated watcher tests to reflect the updated handshake preparation behavior.

and its removal to the removal of such same conn.
Avoid debouncing and cross lock dead locking
onWGDisconnected only checked conn.ctx (the engine-scoped context), never
the watcher's own context. disableWgWatcherIfNeeded cancels the wgWatcherCtx,
not conn.ctx, so a disabled watcher's timeout callback did not see the
cancellation.

handshakeCheck runs lock-free, so between the ctx check in periodicHandshakeCheck
and acquiring conn.mu a fast disconnect/reconnect can slip in: the stale watcher
then acquires the lock and tears down the *new*, healthy connection based on the
old timeout, forcing the guard into an unnecessary reconnect (flap).

Recheck watcherCtx.Err() under conn.mu so a superseded watcher exits without
touching the connection that replaced it.
@riccardomanfrin riccardomanfrin requested a review from pappz July 3, 2026 10:24
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e084c164-a0ab-4cd0-8747-193a16c94142

📥 Commits

Reviewing files that changed from the base of the PR and between ec98c93 and 5740dd2.

📒 Files selected for processing (2)
  • client/internal/peer/conn.go
  • client/internal/peer/wg_watcher.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • client/internal/peer/conn.go
  • client/internal/peer/wg_watcher.go

📝 Walkthrough

Walkthrough

Reworks WGWatcher lifecycle management in client/internal/peer: watcher creation moves to per-attempt enable/disable logic, internal enabled-state tracking is removed, timeout handling now checks both connection and watcher contexts, and tests are updated for the new handshake-prep signature.

Changes

WGWatcher Lifecycle Rework

Layer / File(s) Summary
Watcher state removal
client/internal/peer/wg_watcher.go
Removed enabled/muEnabled state and the sync import from WGWatcher.
Handshake preparation and timeout loop
client/internal/peer/wg_watcher.go
PrepareInitialHandshake no longer returns a boolean; EnableWgWatcher uses the captured baseline without clearing an internal flag; the timeout loop checks ctx after the timer fires; the unchanged-handshake log wording changes.
Conn watcher wiring and stale-context guard
client/internal/peer/conn.go
NewConn no longer pre-creates wgWatcher; enable/disable logic creates and tears down a per-attempt watcher with its own cancelable context; onWGDisconnected rechecks both connection and watcher contexts; resetEndpoint only resets an existing watcher.
Test updates for signature change
client/internal/peer/wg_watcher_test.go
Tests stop asserting the boolean return from PrepareInitialHandshake and remove the testify/require dependency.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • netbirdio/netbird#5133: Both PRs rework the same WGWatcher enable/disable lifecycle and PrepareInitialHandshake behavior in conn.go and wg_watcher.go.
  • netbirdio/netbird#5283: Both PRs touch the conditional resetEndpoint/WGWatcher.Reset path tied to handshake timeout handling.
  • netbirdio/netbird#6626: Both PRs modify the WGWatcher enable/initial-handshake flow and conn.go context checks to prevent stale watchers from tearing down newer connections.

Suggested reviewers: pappz, lixmal

Poem

A watcher wakes, then takes its cue,
No stale old ghost can slip on through.
The tunnel hums, the timers sigh,
With context checks both sharp and sly.
A rabbit nods: the race ran by. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: restarting WGWatcher reliably on fast disconnect/reconnect races.
Description check ✅ Passed The description covers the change, issue context, checklist, and docs decision, but it lacks an actual issue ticket number.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wg_watcher_debounce

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Release artifacts

Built for PR head 5740dd2 in workflow run #16469.

Artifact Link
All release artifacts Download
Linux packages Download
Windows packages Download
macOS packages Download
UI artifacts Download
UI macOS artifacts Download

GHCR images (amd64)

This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy.

@sonarqubecloud

sonarqubecloud Bot commented Jul 3, 2026

Copy link
Copy Markdown

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.

2 participants