HTTPS listener never closes idle connections causing dead sockets to pile up until max connections is reached

Issue Description

Affected: v0.15.4 through v0.16.23, main, v1.0.0.

The HTTPS listener never closes idle HTTP/1.1 connections. Behind a load balancer or NAT that silently drops idle flows (AWS NLB does so after 350 s), the client’s later FIN/RST never reaches Stalwart, so the socket stays ESTABLISHED forever and keeps its maxConnections slot. Once the limit is reached, all new HTTPS connections are refused.

Cause: the http1 builder has no timer:

so hyper discards its default 30 s header_read_timeout, which also bounds keep-alive idle time:

and accepted sockets never get SO_KEEPALIVE

Fix: add .timer(hyper_util::rt::TokioTimer::new()) to the builder, and set TCP keepalive in SocketOpts::apply via socket2::SockRef::set_tcp_keepalive.

Expected Behavior

An idle HTTP connection is closed after a bounded time, like hyper’s default 30 s header read timeout or the idle timeouts on Stalwart’s other listeners, or a dead peer is detected with TCP keepalive. Its maxConnections slot is then released.

Actual Behavior

Idle HTTP connections are never closed by the server. If the peer disappears without its FIN/RST reaching Stalwart, the socket stays ESTABLISHED. These sockets accumulate until the listener’s maxConnections is reached and every new connection hits limit.concurrent-connection.

Reproduction Steps

  1. Run Stalwart with the default HTTPS listener on 443.
  2. Open a connection and leave it idle: openssl s_client -connect <host>:443 -quiet, then send one request or nothing.
  3. Inside the Stalwart host or container, run ss -tni state established '( sport = :443 )'. The connection is still ESTABLISHED after several minutes; it was never closed after 30 s.
  4. To reproduce the leak: put Stalwart behind a load balancer or NAT with an idle timeout (e.g. an AWS NLB at the 350 s default), let clients keep connections idle past that timeout before closing them, and watch the ESTABLISHED count on :443 grow without bound.

Relevant Log Output

2026-09-25T00:21:06Z WARN Concurrent connection limit reached (limit.concurrent-connection) listenerId = “https”, localPort = 443, remoteIp = 62.###.##.###, remotePort = 58818, limit = 8192

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

S3-compatible

Search Engine

PostgreSQL

Directory Backend

OIDC

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Confirmed from the source: the HTTPS/HTTP listener is built with http1::Builder::new().keep_alive(true) and no header_read_timeout or other idle-connection timeout is configured on that hyper builder (crates/http/src/request.rs). The only related listener setting exposed today is tlsTimeout, which only bounds the TLS handshake, and maxConnections, which caps total concurrent sockets on a listener; there is no idle-request timeout or TCP keepalive option on the NetworkListener object (see the field list in the NetworkListener reference). So your read of the behavior is correct: a connection that never sends another request and whose FIN/RST never arrives will sit ESTABLISHED indefinitely and continue to hold its maxConnections slot until the process or OS closes it.

There is no documented workaround inside Stalwart’s own listener configuration for this today (no socketOptions-style TCP keepalive or per-request idle timeout field). The closest mitigations are external: lowering the load balancer/NAT idle timeout below Stalwart’s non-existent server-side timeout won’t help since Stalwart never initiates closure, so in practice operators need to either front the listener with a proxy that terminates idle connections, or reduce the exposure by tuning OS-level TCP keepalive on the listening socket itself (outside Stalwart’s control), until this is addressed in the listener code.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

This has been fixed in v0.16.24 (yet to be released).