Skip to content

Pure TLS server: connection fd closed twice, and the socket wrapper leaks on a failed handshake #3138

Closed

Description

@paul-hammant

The pure-Aether TLS server (AETHER_PURE_TLS=1) and std.http's server disagree about who owns the connection's descriptor. On most paths it is closed twice, and on one path the socket wrapper leaks. Found while reviewing #3137 (#2483). That PR doesn't change this ownership; it only adds failure paths that go through it.

Who closes what

pure_tls_server_accept(fd, …) (std/cryptography/tls13_server/module.ae:619) wraps the fd with tcp_socket_from_fd_owned(fd), a malloc'd TcpSocket that owns it. aether_http_server.c also keeps the number and closes it itself.

Path Aether side C side Result
Handshake succeeds; connection later torn down pure_tls_server_close → close_conn → tcp.close(c.sock) (tls13_server/module.ae:516) closes the fd and frees the wrapper conn_close → close(c->fd) (aether_http_server.c:748) fd closed twice
Cert or key load fails, or the other early returns in pure_tls_server_accept (:624, :626 and three more) tcp.close(sock), then return null close(client_fd) when pt_server_accept returns NULL (aether_http_server.c:5567-5570) fd closed twice
accept() fails (bad ClientHello, TLS 1.2 client, bad key share, Finished mismatch…) returns null without closing or freeing sock close(client_fd) fd closed once, but the 8-byte TcpSocket leaks on every failed handshake

Why the double close matters

The http server serves connections from a thread pool. Between the first close() and the second, another worker's accept() can be given the same descriptor number. The second close() then closes that unrelated live connection, or an fd some other part of the program just opened. The client side hit this exact race and fixed it (std/net/aether_http.c:2115: "Closing it again here closed a NUMBER, which another thread may already have been given for a fresh socket of its own").

Repro for the leak

Run a std.http server with AETHER_PURE_TLS=1 under valgrind --leak-check=full, then send it handshakes it refuses (for example the bad_share.ae probe in #3137's tests/integration/https_pure_tls_vertical/). Each refused handshake leaves:

8 bytes in 1 blocks are definitely lost
   at malloc
   by tcp_socket_from_fd_owned
   by aether_pure_tls_server_accept
   by http_server_drain_connection
   by http_pool_worker

The double close doesn't show up under valgrind. strace -f -e trace=close on the same run shows each connection's fd closed twice.

Suggested fix

Pick one owner and make every path agree. The smallest change matches the client contract (aether_http.c): the Aether side owns the fd once it's handed over.

  • pure_tls_server_accept closes on every failure path (it already does for the early returns), including after accept() fails, which also fixes the leak.
  • aether_http_server.c stops close(client_fd) when pt_server_accept returns NULL.
  • conn_close skips close(c->fd) when c->pure_tls was set, because pt_server_close already closed it.

The alternative is to make the C side the owner, with a wrapper that frees TcpSocket without closing. But the client bridge already went the other way, and keeping the two sides the same is worth more.

Test idea: a regression that runs many refused and successful pure-TLS handshakes in parallel while another thread holds a long-lived fd, then checks that fd is still open. Plus a valgrind/leaks pass over refused handshakes.

Activity

  1. added a commit that references this issue on Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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