Skip to content

fix(websocket): report the real dial failure and close the socket (#1554) - #1556

Merged
acul71 merged 2 commits into
libp2p:mainfrom
yashksaini-coder:fix/websocket-dial-errors-1554
Oct 2, 2026
Merged

acul71 merged 2 commits into
libp2p:mainfrom
yashksaini-coder:fix/websocket-dial-errors-1554

Conversation

@yashksaini-coder

@yashksaini-coder yashksaini-coder commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong?

Fixes #1554

_create_direct_connection handed the Swarm's background nursery to
trio_websocket.connect_websocket_url, which starts its reader task there and then
does await connection._open_handshake.wait() — an Event that is only ever set on
success. So anything failing after the TCP connect never reached the dialing task:

  • the dial waited out handshake_timeout and raised
    WebSocket handshake timeout, whatever the real cause was;
  • the failed connection still owned the socket, so the descriptor stayed open for
    the lifetime of the Swarm.

Measured before this PR, with handshake_timeout = 3.0:

scenario time to raise reported as
nothing listening ~0s the real cause (this path was already fine)
self-signed cert, wss 3.0s WebSocket handshake timeout — never mentions the certificate
upgrade never answered 3.0s WebSocket handshake timeout, socket left open

How was it fixed?

Open the stream and, for wss, complete the TLS handshake in the dialing task,
then hand the established stream to wrap_client_stream so the background nursery
still runs the connection's background tasks — which is what it is actually needed
for.

  • The stream is closed on every failure path, shielded, because the
    cancellation from fail_after would otherwise cancel the close itself and leak
    the descriptor precisely when the handshake timed out.
  • trio reports a refused certificate as BrokenResourceError with the ssl error
    as its cause, so the cause is unwrapped and reported: "certificate verify failed"
    is the actionable part, BrokenResourceError() is not.
  • An OpenConnectionError that already describes its cause is no longer re-wrapped,
    and the generic handler chains with from e (str(e) is empty for several ssl
    and trio errors).

A wss dial against a self-signed certificate now fails in milliseconds naming the
certificate, instead of after the full timeout naming nothing.

Tests

Two regression tests, both confirmed to fail on main and pass here:

  • test_failed_dial_closes_the_tcp_connection — the server reads again after the
    upgrade request and checks that the client hung up. On main:
    AssertionError: the server never saw the client disconnect: the failed dial left its TCP socket open.
  • test_wss_dial_reports_the_certificate_error — asserts the message names the
    certificate, that __cause__ is ssl.SSLCertVerificationError, and that it
    raises in well under the 10s timeout.

Plus test_wss_echo_round_trip_with_a_verified_certificate, the first
end-to-end wss test
in the suite: every existing integration test uses plain
/ws, so the TLS dial path had no coverage, and this PR rewires exactly that path.
It uses a certificate with an IP subjectAltName so the client verifies it with
hostname checking on, and it passes on main too — it is coverage, not a
regression test.

pytest tests/core/transport/websocket/ tests/transport/  ->  125 passed, 1 skipped
   (also clean under -W error::ResourceWarning)
make lint                                               ->  12/12 hooks (mypy, pyrefly)
make build-docs                                         ->  exit 0

The websocket suite also got faster (about 16s to 5s), because failing dials no
longer sit out their timeouts.

Note

make pr reports 4 failures in tests/core/kad_dht/ and
tests/examples/test_dht_chat.py on this branch. They fail on a clean checkout of
main too and pass when rerun serially — a pre-existing race under -n auto,
unrelated to this change.

This is independent of #1550 / #1555 (different function, no overlap) and does not
address #1551, which proposes wrap_client_stream for a different reason — that
change becomes smaller after this one, since the stream is now built here.

To-Do

  • Clean up commit history
  • Add or update documentation related to these changes — behaviour fix, the
    docstring explains the nursery reasoning inline
  • Add entry to the release notes

Cute Animal Picture

quokka

…bp2p#1554)

_create_direct_connection handed the Swarm's background nursery to
trio_websocket.connect_websocket_url, which starts its reader task there and
then waits on an Event that is only ever set on success. Anything failing after
the TCP connect — a certificate the client refuses, a server that accepts and
never answers the upgrade — never reached the dialing task. The dial waited out
handshake_timeout, raised "WebSocket handshake timeout", and the failed
connection kept the socket open for the lifetime of the Swarm.

Open the stream and, for wss, complete the TLS handshake in the dialing task,
then hand the established stream to wrap_client_stream so the background nursery
still runs the connection's background tasks. Close the stream on every failure
path, shielded, because the cancellation from fail_after would otherwise cancel
the close itself and leak the descriptor precisely when the handshake timed out.

trio surfaces a refused certificate as BrokenResourceError with the ssl error as
its cause, so unwrap the chain and report the cause: "certificate verify failed"
is the actionable part. OpenConnectionError raised with a cause of its own is no
longer re-wrapped, and the generic handler now chains, since str(e) is empty for
some ssl and trio errors.

A wss dial against a self-signed certificate now fails in milliseconds naming
the certificate instead of after the full timeout naming nothing.

Adds the first end-to-end wss test: every existing integration test uses plain
/ws, so the TLS dial path had no coverage at all.
@yashksaini-coder

Copy link
Copy Markdown
Contributor Author

@aojea — this is the bug behind a symptom you already produced: test_dns_address_is_dialed_by_name in your #1549 dials a raw TCP listener that never answers the upgrade, and that emits a ResourceWarning for the unclosed client socket. Same root cause as the missing error message — the handshake ran in the Swarm's background nursery, waiting on an Event that is only set on success, so the dialer learned nothing and nothing closed the stream.

This one is independent of #1550/#1555 (different function, no overlap), so it can go in on its own. It also shrinks #1551: the stream is now built in the dialing task, which is exactly what honouring /sni/ needs — trio.SSLStream(..., server_hostname=sni) becomes a one-line change instead of a restructure. Happy to leave #1551 to you, or to follow up if you would rather not.

cc @acul71 for review.

Worth noting for reviewers: this adds the first end-to-end wss test in the suite. Every existing integration test uses plain /ws, so the TLS dial path had no coverage at all — that test passes on main too, so it is coverage rather than a regression check. The two regression tests do fail on main, with the server never saw the client disconnect: the failed dial left its TCP socket open.

Side effect worth having: the websocket suite runs about 3× faster (~16s → ~5s), because tests that expect a dial to fail no longer sit out the full handshake_timeout.

@yashksaini-coder

Copy link
Copy Markdown
Contributor Author

Ready for review — CI green (38/38), current with main, and this one has no dependencies: it is independent of #1549/#1555 and can merge on its own.

@acul71 @sumanjeet0012 for review. Short version of the change: a failed WebSocket dial used to report WebSocket handshake timeout after the full timeout whatever the real cause was, and leaked the TCP socket, because the handshake ran in the Swarm's background nursery waiting on an Event that is only set on success. Now the connect and TLS handshake happen in the dialing task, so a refused certificate is reported as such in milliseconds and the socket is closed on every failure path.

It also adds the first end-to-end wss test in the suite (everything else uses plain /ws), and the websocket suite got ~3× faster because failing dials no longer sit out their timeouts.

Keep the new wss coverage discoverable when the file is run as a script
and match the conventional layout of the rest of the suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

@acul71 acul71 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval

Solid fix for #1554. Moving TCP/TLS handshake into the dialing task and using wrap_client_stream for background work is the right ownership model: refused certificates and unanswered upgrades now surface promptly with their real cause, and failed dials close the socket (shielded against fail_after cancellation).

Strengths

  • Correct root-cause fix matching the issue write-up
  • Shielded aclose_forcefully on failure paths
  • Strong regressions (test_failed_dial_closes_the_tcp_connection, test_wss_dial_reports_the_certificate_error) plus the first end-to-end verifying wss echo test
  • Valid newsfragments/1554.bugfix.rst

Nits addressed before merge

  • PR body now uses Fixes #1554
  • WSS helpers/tests moved above if __name__ == "__main__"

No blockers. Approving.

@acul71
acul71 merged commit 687dadd into libp2p:main Oct 2, 2026
38 checks passed
acul71 added a commit to yashksaini-coder/py-libp2p that referenced this pull request Oct 2, 2026
After rebasing onto main (libp2p#1556), also document new_host trust/opt-out
paths and note that dns_* timeouts apply only to /dnsaddr.

Co-authored-by: Cursor <cursoragent@cursor.com>
acul71 added a commit to yashksaini-coder/py-libp2p that referenced this pull request Oct 2, 2026
After rebasing onto main (libp2p#1556), also document new_host trust/opt-out
paths and note that dns_* timeouts apply only to /dnsaddr.

Co-authored-by: Cursor <cursoragent@cursor.com>
acul71 added a commit that referenced this pull request Oct 2, 2026
…1555)

* fix(websocket): verify the server certificate on wss dials (#1550)

A wss dial with no explicit TLS client configuration built its context with
verify_mode = CERT_NONE and check_hostname = False, so new_host(
enable_websocket=True) accepted any certificate from any server. go-libp2p
dials with a zero tls.Config and js-libp2p uses the platform TLS stack; both
verify against the system roots and check the hostname.

Default to an unmodified ssl.create_default_context(). The previous behaviour
stays reachable through WebsocketConfig(insecure_skip_verify=True), which logs
a warning, and an explicit tls_client_config still wins over both. The flag
name matches the one WebSocketTLSConfig already uses.

The insecure default is why a name-based dial appeared to work while the
transport resolved names to an IP first: hostname verification could not have
succeeded against a certificate issued for the name. Stacked on #1549, which
dials /dns, /dns4 and /dns6 by name; verification on its own would otherwise
break those dials.

libp2p's own handshake authenticates the peer inside the WebSocket, so this was
never a stream integrity or confidentiality hole; what an unverified outer TLS
allowed was terminating the connection on the path unnoticed.

* fix(websocket): preserve insecure_skip_verify in combine_configs

After rebasing onto main (#1556), also document new_host trust/opt-out
paths and note that dns_* timeouts apply only to /dnsaddr.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: acul71 <34693171+acul71@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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.

WebSocket transport reports every post-connect dial failure as a handshake timeout and leaks the TCP socket

2 participants