Skip to content

WireGuard connection fails on Python 3.14: run_in_executor socket checks always return False in tcpcheck.py #20

Description

@decorahensisire

Summary

WireGuard connections fail on Python 3.14 with Error: Connection failed. Try connecting to a different server or check your network settings. The root cause is that tcpcheck.is_any_port_reachable() always returns False when called from within the CLI's asyncio event loop on Python 3.14, even though the target ports are genuinely reachable.

Environment

  • OS: EndeavourOS (Arch Linux)
  • python-proton-vpn-api-core: 5.2.5-1
  • proton-vpn-cli: 1.0.1-1
  • Python: 3.14.6
  • NetworkManager: 1.58.0

Steps to Reproduce

  1. Set protocol to WireGuard (~/.config/Proton/VPN/settings.json"protocol": "wireguard")
  2. Run protonvpn --verbose connect

Observed Output

CONN.CONNECT:START | Protocol: wireguard
TCP check: ip=<server_ip> ports=[443, 7770, 8443]
TCP check result: False
VPN server NOT reachable.
CONN:STATE_CHANGED | Error

This happens for every server selected. The same ports are confirmed reachable via direct socket tests (/dev/tcp, socket.connect_ex).

Root Cause

is_any_port_reachable() in tcpcheck.py wraps loop.run_in_executor() socket calls via asyncio.create_task + asyncio.as_completed. On Python 3.14, this combination silently returns False for all ports when called from within the CLI's existing event loop, even though the sockets would connect successfully if called directly.

Verified:

  • is_port_reachable(ip, port) called synchronously → True
  • asyncio.run(is_any_port_reachable(ip, ports)) in a fresh event loop → True
  • Same call from within the CLI's event loop during connect → False

There is also a pre-existing logic bug in the original as_completed loop: it returns on the very first completed task regardless of its result, meaning a single False (port refused quickly) would short-circuit before other ports succeed.

Workaround

Setting server_reachable = True unconditionally in networkmanager.py allows WireGuard to connect successfully, confirming the tunnel itself is fully functional.

Suggested Fix

Replace the create_task + as_completed pattern with asyncio.gather:

results = await asyncio.gather(
    *[_is_port_reachable(port) for port in ports],
    return_exceptions=True
)
return any(r is True for r in results)

Note: this also fixes the logic bug. Further investigation into why run_in_executor socket results are wrong inside the CLI event loop on Python 3.14 may be warranted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions