Released4 Aug 2026Commitfe4b756fLine0.16.x

v0.16.5

Bound the TLS handshake so a silent peer cannot block a process forever

Notes

connect-timeout-ms stops at the moment the peer accepts the connection. Everything after that, the entire TLS handshake, was an unbounded blocking read. A server that accepts a TCP connection and then never sends a ServerHello left the caller parked in read() with no way out. Under Sigil's cooperative scheduler that does not fail one request, it freezes the whole process, including whatever else was sharing the scheduler.

This is not a theoretical hazard. It ran a production monitoring service for 55 days while every health surface, the process, the service status and the listening port, reported it healthy.

tls-connect and tls-upgrade now take an optional handshake timeout, and two new procedures report why a connection attempt failed.

Changes

  • tls-connect hostname port [connect-timeout-ms [handshake-timeout-ms]] bounds the handshake. The deadline is a TOTAL over the handshake, enforced on each individual read as a clamp on how long that read may wait.

That distinction carries the whole value of this release, so it is worth stating why. A bound applied only per read is defeated by a peer that DRIPS rather than one that goes silent: mbedTLS does not return between handshake steps and restarts its read timeout on every partial read, so each dribbled byte resets the clock. Measured against an implementation that got this wrong, a peer sending one byte every 600 ms made a 1000 ms bound take 13 seconds to fire, and it never fired at all while the drip continued. A per-read timeout is still kept underneath as a backstop; the two bound different things and neither replaces the other.

  • tls-upgrade socket hostname [handshake-timeout-ms] gains the same bound. An upgraded socket is already connected, so all of its handshake sits past the connect phase and its unbounded window was strictly wider than tls-connect's.
  • tls-connect/status and tls-upgrade/status return (status . connection-or-#f). "handshake-timeout" means the peer accepted and then went silent; "handshake-failed" means the peer refused us. Those are different facts and callers retry them differently, which a single #f cannot express.
  • tls-write on a non-blocking connection returns the bytes it actually committed, possibly zero, instead of spinning. It previously retried WANT_WRITE in a hot loop that never returned and never yielded, so a peer that stopped reading could pin a CPU indefinitely and no caller could bound a write at all. Blocking connections are unchanged. tls-write also gains optional start and end byte offsets, mirroring socket-write, so a write loop resending the tail of a large body stays O(n) rather than O(n squared).

Fixed

  • A file descriptor leak on the tls-upgrade failure path. The socket object is marked closed before the handshake runs, so nothing else owned that descriptor and nothing would ever close it.
  This one matters more than its size suggests. The leak was hard to reach
  while handshake failures were rare, and bounding the handshake makes
  timeouts an ordinary outcome. Shipping the bound without this would have
  traded a hang for descriptor exhaustion weeks later, by which time the
  connection between the two would have been invisible.

Upgrade notes

Nothing to do. Both timeouts are opt-in: omitted or non-positive keeps the original blocking behaviour exactly, and an established connection's reads are unchanged either way because the handshake deadline is unwound before the connection is returned. On Windows the handshake timeout is ignored, matching how the connect timeout already behaves there.

Callers that want the protection pass a handshake timeout explicitly. Anything long running that makes outbound HTTPS calls should.

Commit

fe4b756f

The commit this tag names.

Releases