netbirdio/netbird · error

failed to close wireguard interface %s: %w

Error message

failed to close wireguard interface %s: %w

What it means

Emitted from WGIface.Close() when tun.Close() fails during teardown. Close() first frees the WireGuard proxy, then deliberately releases w.mu before closing (wireguard-go's device.Close waits for goroutines that re-enter WGIface methods), and appends the close error to a multierror that also carries later teardown failures. It means the TUN/userspace device could not be closed cleanly, typically an already-invalidated fd or a double close.

Source

Thrown at client/iface/iface.go:230

func (w *WGIface) Close() error {
	w.mu.Lock()

	var result *multierror.Error

	if err := w.wgProxyFactory.Free(); err != nil {
		result = multierror.Append(result, fmt.Errorf("failed to free WireGuard proxy: %w", err))
	}

	// Release w.mu before calling w.tun.Close(): the underlying
	// wireguard-go device.Close() waits for its send/receive goroutines
	// to drain. Some of those goroutines re-enter WGIface methods that
	// take w.mu (e.g. the packet filter DNS hook calls GetDevice()), so
	// holding the mutex here would deadlock the shutdown path.
	tun := w.tun
	w.mu.Unlock()

	if err := tun.Close(); err != nil {
		result = multierror.Append(result, fmt.Errorf("failed to close wireguard interface %s: %w", w.Name(), err))
	}

	if nbnetstack.IsEnabled() {
		return errors.FormatErrorOrNil(result)
	}

	if err := w.waitUntilRemoved(); err != nil {
		log.Warnf("failed to remove WireGuard interface %s: %v", w.Name(), err)
		if err := w.Destroy(); err != nil {
			result = multierror.Append(result, fmt.Errorf("failed to remove WireGuard interface %s: %w", w.Name(), err))
			return errors.FormatErrorOrNil(result)
		}
		log.Infof("interface %s successfully removed", w.Name())
	}

	return errors.FormatErrorOrNil(result)
}

View on GitHub (pinned to 93e97f4bf1)

Solutions

  1. Ensure Close runs exactly once per interface lifecycle (the engine already sequences it)
  2. Run `netbird down` before stopping, rebuilding, or restarting the daemon
  3. Read the preceding log lines: the underlying Close error and wireguard-go device errors appear just before
  4. If it recurs, verify no goroutine still writes to the TUN after Close begins

Example fix

// before
w.Close()
w.Close() // second teardown hits a dead fd

// after
var closed atomic.Bool

func closeOnce() error {
    if !closed.CompareAndSwap(false, true) {
        return nil
    }
    return w.Close()
}
Defensive patterns

Strategy: try-catch

Try / catch

if err := w.Close(); err != nil {
    if errors.Is(err, os.ErrClosed) || strings.Contains(err.Error(), "already closed") {
        return nil
    }
    return err
}

Prevention

When it happens

Trigger: Calling Close() twice (second close hits a dead fd), the TUN device removed underneath the process, or a userspace/netstack device whose Close returns an error; the netstack-enabled branch returns right after this step.

Common situations: Duplicate shutdown paths (engine Stop plus a signal handler); test harnesses closing the iface directly; running netbird down twice; rebuilding the daemon without a prior down.

Related errors


AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16). Data as JSON: /api/errors/8ffdf6fe6df98bdc. Report an issue: GitHub.