internal_upstream: provision mutable filter state placeholders, readable on pool failure - #46797
Closed
MyUmmaGumma wants to merge 3 commits into
Closed
internal_upstream: provision mutable filter state placeholders, readable on pool failure#46797MyUmmaGumma wants to merge 3 commits into
MyUmmaGumma wants to merge 3 commits into
Conversation
…ble on pool failure Signed-off-by: Keerti Lakshminarayan <keerti2882@gmail.com>
…ble on pool failure Signed-off-by: Keerti Lakshminarayan <keerti2882@gmail.com>
Signed-off-by: Keerti Lakshminarayan <keerti2882@gmail.com>
MyUmmaGumma
marked this pull request as ready for review
August 19, 2026 15:32
MyUmmaGumma
requested review from
botengyao,
kyessenov,
mattklein123 and
yanavlasov
as code owners
August 19, 2026 15:32
|
CC @envoyproxy/api-shepherds: Your approval is needed for changes made to |
Contributor
Author
|
Closing this PR. We found an extension-only solution that requires no Envoy core changes: a shared mutable filter-state holder is passed through internal_upstream, populated by an inner network filter from tcp_proxy’s CONNECT response headers, and read by the outer HTTP filter. We verified it on the actual CONNECT rejection/pool- failure path. Thanks for the guidance and reviews. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Commit Message
internal_upstream: provision mutable filter state placeholders, readable on pool failure
Additional Description
In the internal-listener tunneling topology — outer L7 listener → cluster with
transport_socket: envoy.transport_sockets.internal_upstream→ internal listener →tcp_proxyissuing CONNECT — the response captured by
propagate_response_headerslands on the innerconnection's filter state. The outer L7 listener, which received the original request, cannot read
it. A non-2xx CONNECT (an authorization denial returning 403, say) therefore reaches the client as a
canonical 503.
This is the alternative @kyessenov suggested in
#45237: rather than adding
a reverse propagation direction, provision an empty, mutable filter state object at the boundary,
forward-share it by reference over the existing
PassthroughStatepath, and lettcp_proxyfill itin place. One
shared_ptr, visible on both sides. No new sharing direction and no close-orderingdependency.
Relationship to #45237
The two are alternatives for the same goal, not dependencies. Either can land alone.
SharedWithDownstreamConnectionOnClosedirection; objects copied out at closeIoHandle::addOnPreCloseCallback,PassthroughState::captureReverse/mergeReverse, new enum variantThe reverse primitive is the more general channel and remains the better long-term contribution. This
one is smaller, order-independent, and adds no new sharing semantics. Part 3 below is required by
both.
Why the object is provisioned at the boundary, and not seeded from a downstream filter
The same object can be seeded by an ordinary downstream HTTP filter on the outer listener, with no core
change at all. That variant works — it is one of the test cases below — and its smaller footprint makes
it the obvious first suggestion. It is not viable for the deployment this is written for because
the outer listener here is a sidecar's shared outbound listener. It carries every outbound request
from the local application, whether that request is tunnelled or not. A downstream filter on it runs for
all of them, and the object cannot be narrowed to the cases that need it:
it to.
The payload only matters in the error case, and only on the tunnelled path. Seeding downstream therefore
means paying an allocation on every outbound request to serve a rare error on a subset of them.
Provisioning at the transport socket avoids that without giving up the idea: the
internal_upstreamtransport socket exists only on clusters that tunnel, so the object is created only on that path — and
once per connection, not once per request. That is the whole reason this PR provisions the object one
hop in rather than at the listener.
The topology and the cost model are set out in more detail in
this comment on #45237.
The change, in three parts
Part 1 —
internal_upstreamcan be told to provision empty filter state objects. A newprovisioned_placeholder_factoriesfield names registeredFilterState::ObjectFactoryinstances.Each name is resolved when the config loads, so a name that does not resolve is a config rejection
rather than a silent no-op at request time. For each one, an empty object is placed on the upstream
connection and forward-shared into the internal connection over the existing
PassthroughStatepath.It is one object, not a copy, so either side can see what the other writes.
Where the object lives, since @kyessenov asked about multiplexing on #45237: on the upstream
connection this transport socket creates. There is one of those per cluster, so the object is never
shared between different upstreams, and it is created per connection rather than per request.
Part 2 —
tcp_proxyfills such an object instead of replacing it. When it captures a CONNECTresponse and an object for that key already exists, it now writes the headers into that object rather
than storing a new one over the top. Anything else holding the original therefore sees the headers.
With no object already present, the behaviour is unchanged. An object factory is registered for the
existing
envoy.tcp_proxy.propagate_response_headerskey so that it can be named from config.Part 3 — the router records upstream filter state when a connection fails, not only when it
succeeds. Today
onPoolReadyrecords it andonPoolFailuredoes not, because the failure callbackis given no
StreamInfo. A rejected CONNECT is a connection failure, so%UPSTREAM_FILTER_STATE%reads nothing on exactly the path where the response status matters.
The HTTP connection pool now passes the failing connection's filter state along with the failure, and
the router records it the same way it already does on success. The new callback forms default to the
existing ones, so no current implementation has to change, and the existing failure handling still
runs unaltered.
Why Part 3 is in this PR rather than its own
Part 3 is the failure-path counterpart of an existing success-path capture, and every approach that reads the outer connection's filter state needs it —
including #45237, which defers it as an intended follow-up. On that symmetry argument alone it would stand as a PR of its own.
It is included in this PR because because standing alone it is an API widening with no observable behaviour change and no
test that demonstrates value. Parts 1–2 are that use case: together they make the failure-path
capture observable end to end. If reviewers would rather see Part 3 separately, happy to split it —
Parts 1–2 are then blocked until it lands.
What is not in this PR
side. Acting on it is a filter, and out of scope here.
propagateResponseHeadersis invoked on every CONNECT response, before the validity check, so the object always describes
the CONNECT that established the tunnel it is attached to.
Tcp::ConnPoolImplhas the identical asymmetry. ItsonPoolFailureoverride does take the new parameter, because the base virtual requires it, butignores the value; carrying it further needs a matching overload on
Tcp::ConnectionPool::Callbacksand a hop in the
upstreams/http/tcpadapter. Left out deliberately — nothing here exercises thatpath — and it is a small self-contained follow-up.
tcp_proxywire behaviour (CONNECT semantics, reset behaviour, retries).Risk Level
Low.
provisioned_placeholder_factoriesempty,internal_upstreambehavesexactly as today and
tcp_proxytakes its existingsetDatapath.onPoolFailureoverloads default to the existing three-argument form, andActiveClient::connectionFilterState()defaults tonullptr. No existing implementor changes.UpstreamRequest::onPoolFailureis additive; the existing three-argument body stillruns, so failure handling is otherwise untouched.
an empty object rather than a null dereference.
Testing
New integration tests in
test/extensions/transport_sockets/internal_upstream/fwd_mutable_placeholder_integration_test.cc.The topology in each is: outer HCM → internal-listener cluster carrying the
internal_upstreamtransport socket → internal listener running
tcp_proxywithpropagate_response_headers: true→upstream that rejects the CONNECT with a non-2xx.
%FILTER_STATE%. Covers theby-reference in-place mutation independently of Part 1.
internal_upstreamviaprovisioned_placeholder_factories, readvia
%UPSTREAM_FILTER_STATE%. Covers Parts 1 and 2.internal_upstreamwraps is TLS rather than raw buffer. With no handshakethe pool reports readiness as soon as the user-space connect returns, so the failure path is never
taken. A TLS handshake has to travel through the CONNECT tunnel, so a rejected CONNECT means the
pool reports failure instead. This is the case that covers Part 3, and it asserts
cluster.internal_listener.upstream_cx_connect_fail > 0so the premise cannot silently rot backinto a readiness test.
Verified end to end in custom deployment mirroring the architecture above
Docs Changes
internal_upstream.proto: documentsprovisioned_placeholder_factories, including that a namealready present on the connection from the downstream side is left untouched (no double
provisioning), and that the objects must be mutable for a downstream consumer to fill them.
Release Notes
changelogs/current/new_features/sockets__internal-upstream-provisioned-placeholder-factories.rst:changelogs/current/new_features/router__upstream-filter-state-on-pool-failure.rst:changelogs/current/new_features/tcp_proxy__fill-existing-tunnel-response-headers-object.rst:Platform Specific Features
N/A.
Related
CONNECT status to reach the client, and notes that
UPSTREAM_FILTER_STATE(envoy.tcp_proxy.propagate_response_headers)at the egress HCM shows nothingwhile the inner
tcp_proxyaccess log does show the 403. Part 3 is the fix for that specificobservation. The issue was closed by tcp_proxy: record non-2xx CONNECT response status in transport failure reason #44157, which added visibility on the inner side — the side
the reporter already had — so what the issue actually asks for is still outstanding.
No
Fixes:line, because this is not the whole of #43977: it makes the status readable at theouter listener, and the issue asks for it to reach the client's response, which needs the
response-rewriting filter that is out of scope here.