Skip to content

out_forward: TLS keepalive connection recycled after peer close_notify (SSL_ERROR_ZERO_RETURN) → repeated "cannot get ack" and data loss #12076

Description

@ku524

Bug Report

What happens

With out_forward using tls on + Require_ack_response true + keepalive (default on), if an upstream — or a TLS-terminating proxy in front of it — sends a TLS close_notify on an idle pooled connection, that connection is returned to the keepalive pool instead of being destroyed. On the next reuse, SSL_read for the ack returns SSL_ERROR_ZERO_RETURN immediately from cached session state (no network read), so the flush logs cannot get ack and fails. The same poisoned connection keeps being handed out, so every retry fails identically until Retry_Limit is exhausted → dropped_records (silent log loss).

Root cause (two parts)

  1. tls_net_read (src/tls/openssl.c) does not flag the closed session. On SSL_read returning SSL_ERROR_ZERO_RETURN (and SSL_ERROR_SSL) it returns -1 without setting connection->net_error; only SSL_ERROR_SYSCALL sets it. flb_upstream_conn_release() only refuses to recycle a connection when net_error is set, so a TLS-half-closed connection is recycled back into the keepalive pool.
  2. flb_upstream_conn_get() cannot detect the half-close. It validates a pooled connection only with getsockopt(SO_ERROR), which is 0 for a socket whose peer sent close_notify (the TCP layer may still be ESTABLISHED/CLOSE_WAIT). There is no recv(MSG_PEEK)/EOF probe, so the half-closed connection passes the health check and is reused.

Net effect: a "poisoned" connection is reused indefinitely, producing repeated instant cannot get ack.

Reproduction (observed)

  • Sender: Fluent Bit v3.2.3, out_forward with tls on, tls.verify off, Require_ack_response true, keepalive on, net.io_timeout unset.
  • Path: sender → (TLS) istio/envoy gateway that terminates TLS → (plaintext) fluentd/fluent-bit aggregator. When the aggregator pod is churned, envoy relays a close_notify onto the sender's pooled TLS connection while the downstream TCP stays up.
  • Result: bursts of [output:forward:...] cannot get ack; no per-failure TCP FIN/RST; the connection stays reused. strace shows the failing flushes do app-data writes but no network read for the ack (ZERO_RETURN from cached session state). After Retry_Limit is exhausted, dropped_records climbs.
  • Config-only mitigations that work while keeping keepalive on: net.keepalive_max_recycle N forces the poisoned connection out of the pool after N reuses so it self-heals; net.keepalive off also avoids it. net.io_timeout does not help — the failure is instant (a cached ZERO_RETURN), not a hang.

Impact

Silent, sustained log loss on any TLS forward path sitting behind a peer/proxy that half-closes: graceful upstream shutdown, LB/proxy connection recycling, or proxy upstream failover.

Versions

Reproduced on 3.2.3. The tls_net_read / flb_upstream_conn_get code paths are unchanged on current master.

Suggested fix

  • Primary (most fundamental, TLS transport layer): in tls_net_read (src/tls/openssl.c), set connection->net_error on SSL_ERROR_ZERO_RETURN (and SSL_ERROR_SSL) so flb_upstream_conn_release() destroys the half-closed connection instead of recycling it. ZERO_RETURN means the peer closed the TLS session — such a connection must never be returned to the pool. Fixing it here covers every consumer of the upstream pool, not just out_forward.
  • Complementary: add an EOF / recv(MSG_PEEK) liveness probe in flb_upstream_conn_get() so a connection closed while idle in the pool is caught before reuse (removes even the single first-reuse failure).

Relation to #12046

#12046 force-closes on any non-FLB_OK flush result in out_forward, which would incidentally cover this case (a failed ack read returns non-OK). However, its enumerated triggers are partial-write / handshake / WOULDBLOCK, not the TLS-read ZERO_RETURN path, and it compensates at the plugin level rather than fixing the net_error handling at the TLS layer. The primary fix above addresses the root and benefits all pool consumers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions