Skip to content

Add connectionTimeout to bound the TCP connect and TLS handshake - #649

Merged
shamblett merged 1 commit into
shamblett:pr649from
sagar448:connect-timeout
Oct 7, 2026
Merged

shamblett merged 1 commit into
shamblett:pr649from
sagar448:connect-timeout

Conversation

@sagar448

@sagar448 sagar448 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Problem

A connection attempt on a secure (TLS) connection has no upper bound once the TCP
connection is made. socketTimeout is passed to SecureSocket.connect(timeout:), which
only bounds RawSocket.connect — the TLS handshake that follows has no timeout in
dart:io. So a peer that accepts the TCP connection but never answers the TLS client
hello (a misbehaving middlebox, a captive portal, a firewall doing DPI, a wedged broker)
holds connect() forever, with or without socketTimeout, and an application's own
reconnect loop stops with it. Setting socketTimeout also sets connectTimeoutPeriod to
10 ms, which is too short for a remote broker's CONNACK, so it can't be used for this
anyway. #589 noted a connect bound "needs its own option to make it clear what it's for".

Reproduction

// A peer that accepts TCP and never answers the TLS client hello.
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final held = <Socket>[];
server.listen(held.add);
final client = MqttServerClient.withPort(
    '127.0.0.1', 'repro', server.port, maxConnectionAttempts: 1)
  ..secure = true
  ..securityContext = SecurityContext()
  ..socketTimeout = 3000;
await client.connect(); // never completes (with or without socketTimeout)

(Same with plain SecureSocket.connect('127.0.0.1', port, timeout: Duration(seconds: 2)).)

Change

A new MqttServerClient.connectionTimeout (milliseconds, null by default) bounds each
connection attempt's TCP connect and, for a secure connection, its TLS handshake, on TCP
and secure TCP connections:

  • When it elapses the attempt fails with a NoConnectionException through the existing
    failed-connect path (the same path as a refused connection or a failed handshake),
    so existing error handling applies. connectionStatus.state is faulted.
  • dart:io cannot abort a TLS handshake in progress, so a socket whose handshake
    completes after the timeout is destroyed when it does and is never used (no CONNECT
    is sent on it).
  • With no socketTimeout set, the TCP connect is given the same timeout, so the runtime
    cancels a still-pending connect (e.g. a SYN black hole) instead of leaving it to the OS.
  • socketTimeout and connectTimeoutPeriod are unchanged. With connectionTimeout unset
    the code path is unchanged.
  • Implemented once in MqttServerConnection and used by the four connect/connectAuto
    call sites of the normal and secure connections. It is passed to them as a plain field,
    so no constructor signatures change.

Tests

  • mqtt_client_connection_unsecure_test.dart: Connection Timeout - TCP connect never
    completes
    (fails at the timeout, not after the CONNACK wait; the TCP connect is given
    the timeout) and Connection Timeout - socket timeout unchanged.
  • mqtt_client_connection_secure_test.dart, new group Connection Timeout: a real
    loopback peer that never answers the TLS client hello (fails at the timeout); a peer
    that completes the handshake only after the timeout (the late socket is closed and
    receives no bytes); a normal TLS broker connects with the timeout set.
  • Full suite: all existing tests unchanged. Locally (macOS) one pre-existing failure,
    MockBroker Connection Keep Alive - Successful response, fails the same way without
    this change: test/pem/localhost.cert has no subjectAltName, which the macOS verifier
    rejects. It passes on Linux.
  • Also checked against a real broker (mosquitto 2.1 with TLS + client certificates, and
    behind an ALPN-requiring TLS terminator): connects as before. A peer that stalls the
    handshake, and a SYN black hole (192.0.2.1), fail at the timeout.

Backward compatibility

Additive and opt-in: one new nullable field, default null = today's behaviour; no
signature changes; socketTimeout semantics untouched. Websocket connections are not
covered (like socketTimeout). Happy to extend it to MqttServerWsConnection/Ws2 if
you'd like. I added a CHANGELOG entry under a provisional 10.12.0 heading. Feel free to
fold it into your release commit or rename the option (e.g. a Duration).

🤖 Generated with Claude Code

socketTimeout bounds only the TCP connect: SecureSocket.connect's timeout
covers RawSocket.connect, not the TLS handshake that follows. A peer that
accepts the TCP connection but never answers the TLS client hello holds a
connection attempt forever, with or without socketTimeout.

MqttServerClient.connectionTimeout (milliseconds, null by default) bounds
the TCP connect and, for a secure connection, the TLS handshake of each
connection attempt on TCP and secure TCP connections. When it elapses the
attempt fails with a NoConnectionException through the existing failed
connect path. Without a socketTimeout the TCP connect is given the same
timeout, so the runtime cancels a pending connect. The runtime cannot abort
a TLS handshake in progress, so a socket whose handshake completes after
the timeout is destroyed when it does and is never used.

connectTimeoutPeriod and socketTimeout are unchanged, and with
connectionTimeout unset the behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shamblett
shamblett changed the base branch from master to pr649 October 7, 2026 08:29
@shamblett
shamblett merged commit fb0d4cb into shamblett:pr649 Oct 7, 2026
1 of 2 checks passed
shamblett added a commit that referenced this pull request Oct 7, 2026
* feat: Add connectionTimeout to bound the TCP connect and TLS handshake (#649)

socketTimeout bounds only the TCP connect: SecureSocket.connect's timeout
covers RawSocket.connect, not the TLS handshake that follows. A peer that
accepts the TCP connection but never answers the TLS client hello holds a
connection attempt forever, with or without socketTimeout.

MqttServerClient.connectionTimeout (milliseconds, null by default) bounds
the TCP connect and, for a secure connection, the TLS handshake of each
connection attempt on TCP and secure TCP connections. When it elapses the
attempt fails with a NoConnectionException through the existing failed
connect path. Without a socketTimeout the TCP connect is given the same
timeout, so the runtime cancels a pending connect. The runtime cannot abort
a TLS handshake in progress, so a socket whose handshake completes after
the timeout is destroyed when it does and is never used.

connectTimeoutPeriod and socketTimeout are unchanged, and with
connectionTimeout unset the behaviour is unchanged.

Co-authored-by: Sagar Jaiswal <20596140+sagar448@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Formatting

* DCM

* Changelog

---------

Co-authored-by: Sagar Jaiswal <jas_96@hotmail.co.uk>
Co-authored-by: Sagar Jaiswal <20596140+sagar448@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@shamblett

Copy link
Copy Markdown
Owner

Thanks for this, especially the tests, a lot of folks often forget these.

Merged, package re-published at version 10.12.0

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