Fix TransformVConnection resource leak when abort_tunnel() is called with active request transform - #13574
Fix TransformVConnection resource leak when abort_tunnel() is called with active request transform#13574sxia-aviatrix wants to merge 7 commits into
Conversation
when is called while a request transform plugin registered at is active.
There was a problem hiding this comment.
Pull request overview
Fixes a use-after-free crash in ATS’s HTTP state machine when an early origin response triggers abort_tunnel() while a request transform is active, and adds an AuTest regression test + supporting test plugin to reproduce the timing-sensitive scenario.
Changes:
- Clean up
post_transform_info.entryaftertunnel.abort_tunnel()to preventkill_this()→vc_table.cleanup_all()from closing a stale VC pointer. - Add a new AuTest (
post_early_response_transform.test.py) plus a partial-POST client helper to reproduce the early-response / request-transform timing case. - Add a dedicated test plugin (
null_transform_request) and wire it into the test-plugin build.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/proxy/http/HttpSM.cc |
Clears the stale post-transform vc_table entry after abort_tunnel() to avoid use-after-free during later cleanup. |
tests/tools/plugins/null_transform_request.cc |
New test plugin registering a request transform at TS_HTTP_READ_REQUEST_HDR_HOOK to reproduce the pre-tunnel transform case. |
tests/tools/plugins/CMakeLists.txt |
Builds the new null_transform_request autest plugin. |
tests/gold_tests/slow_post/post_early_response_transform.test.py |
New AuTest scenario driving a partial POST through ATS with the request transform active and an origin that replies immediately. |
tests/gold_tests/slow_post/partial_post_client.py |
Helper client that sends a large Content-Length but only a small body to trigger abort behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
tests/tools/plugins/null_transform_request.cc:60
output_readerallocated viaTSIOBufferReaderAlloc()is never freed. Prefer freeing the reader (e.g., viaTSIOBufferReaderFree(data->output_reader)) before destroying the buffer to avoid leaks and make ownership explicit.
if (data) {
if (data->output_buffer) {
TSIOBufferDestroy(data->output_buffer);
}
TSfree(data);
}
tests/gold_tests/slow_post/post_early_response_transform.test.py:73
- This assertion will pass even on the client's
timeout/error paths because they also printGot response:. To make the regression test more robust, assert on a specific successful response pattern (e.g.,Got response: HTTP/1.1) and/or explicitly fail ontimeoutto avoid false positives.
p.Streams.All += Testers.ContainsExpression('Got response', 'Verify client received a response from ATS')
src/proxy/http/HttpSM.cc:2157
- This fix relies on mutating an internal flag (
in_tunnel) to forcevc_table.cleanup_entry()behavior, which tightly couplesHttpSMtovc_table/entry invariants. Consider encapsulating this as a dedicated helper (e.g.,cleanup_post_transform_entry_after_abort()), or better: haveabort_tunnel()/the tunnel own clearing any associatedvc_tableentries, so callers don’t need to manually adjust entry state to achieve correct cleanup.
// abort_tunnel() does not clean up vc_table entries. If a request
// transform is present, post_transform_info.entry still points at the
// TransformVConnection whose chain will be freed by the abort cascade.
// Clean it up now so cleanup_all() in kill_this() does not call
// do_io_close() on freed memory.
if (post_transform_info.entry != nullptr) {
post_transform_info.entry->in_tunnel = false;
vc_table.cleanup_entry(post_transform_info.entry);
post_transform_info.entry = nullptr;
}
bneradt
left a comment
There was a problem hiding this comment.
Thanks for the fix.
Please reorganize tests/gold_tests/slow_post/post_early_response_transform.test.py as a Test class. See tests/gold_tests/ats_probe/ats_probe.test.py as an example.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/proxy/http/HttpSM.cc:2152
- The comment implies
abort_tunnel()frees the transform chain, butHttpTunnel::abort_tunnel()only cancels I/O and resets the tunnel bookkeeping (it does not close or delete VCs). This is misleading for future maintenance and obscures why the explicitcleanup_entry()is needed here.
// abort_tunnel() does not clean up vc_table entries. If a request
// transform is present, post_transform_info.entry still points at the
// TransformVConnection whose chain will be freed by the abort cascade.
// Clean it up now so cleanup_all() in kill_this() does not call
// do_io_close() on freed memory.
bneradt
left a comment
There was a problem hiding this comment.
Thanks for digging into this one — the teardown gap you found looks real. My main concern is that the mechanism described in the PR (and in the code comment and the test docstring) doesn't hold up: cleanup_entry() only calls do_io_close() when in_tunnel == false, and at this point it is true, so cleanup_all() can't have been dereferencing the stale VC. Details inline.
I do think there's a genuine bug here, just a different one: before this patch the TransformVConnection and the plugin's transform continuations are never closed on the abort path — a leak rather than a use-after-free. If that's what you were chasing, the fix is close to right, but it should say so directly instead of overwriting the in_tunnel ownership flag. If there really is a crash, could you attach the stack trace or ASAN report so we can confirm this addresses it?
The rest of the comments are on the test and the test plugin. Also flagging that one of the Copilot comments below is incorrect — replied in that thread.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
tests/gold_tests/slow_post/post_early_response_transform.test.py:6
- The module docstring describes the failure mode as a "use-after-free", but the PR description and the code comment in HttpSM.cc describe a stale
post_transform_info.entrythat preventscleanup_entry()from closing the transform VC (i.e., a leak). Updating the docstring to match the actual bug being tested will avoid confusion for future readers.
When a POST request has a request transform and the origin responds before the
full body is forwarded through the transform chain, abort_tunnel() is called.
Without the fix, post_transform_info.entry is left stale in the vc_table,
causing a use-after-free in cleanup_all().
tests/tools/plugins/null_transform_request.cc:8
- The header comment says this reproduces a "use-after-free", but the PR description indicates the issue is that a request transform VC is never closed after
abort_tunnel()(resource leak). Adjust the comment so the plugin’s purpose matches the bug being fixed/tested.
Used by post_early_response_transform.test.py to reproduce a use-after-free
in HttpSM::state_read_server_response_header() when abort_tunnel() is called
while a request transform is active. The transform passes request body data
through unmodified.
tests/gold_tests/slow_post/partial_post_client.py:38
- The PR description says the test sends a very large
Content-Length(10,000,000) and trickles body chunks slowly, but this client currently usesContent-Length: 100000and sends a single 4096-byte chunk in onesendall(). Consider aligning either the PR description or this client behavior to reduce confusion and ensure the test reliably exercises the intendedabort_tunnel()path.
request = (
f'POST / HTTP/1.1\r\n'
f'Host: quick.server.com\r\n'
f'Content-Type: application/octet-stream\r\n'
f'Content-Length: 100000\r\n'
…to avoid duplication and resolve comments
|
[approve ci] |
|
Thank you for the updates. I'm going to build a dev rpm of this and try it in production to make sure it's stable for us. If that goes well I'll approve. |
bneradt
left a comment
There was a problem hiding this comment.
This is much better — thanks for turning it around so quickly. The HttpSM.cc change now says what it does and does what it says, folding the test into quick_server.test.py came out cleaner than I expected, and teaching tunnel_transform.cc a mode flag beats a second copy of the plugin. The C++ fix itself looks right to me.
One item I'd call blocking, and it's in the test: the client's connection-reset message is worded so that it satisfies the ContainsExpression('HTTP/1.1') check, which means the tester labelled "Verify client received an HTTP response" passes even when no response arrived. Worth nailing down what ATS is actually expected to return here.
Everything else is smaller — one nit on the fix, a note that the nbytes change in the plugin is load-bearing and should be commented (I traced why: INT64_MAX would make state_request_wait_for_transform_read() fail the transaction outright, so without it the new run wouldn't exercise the abort path at all), and a flag that a leak regression won't be caught by this test outside ASAN.
| // with in_tunnel=true, which causes cleanup_entry() to skip | ||
| // do_io_close() — leaking the VC. Close it explicitly here. | ||
| if (post_transform_info.entry != nullptr) { | ||
| post_transform_info.vc->do_io_close(); |
There was a problem hiding this comment.
This is the shape I was hoping for — the comment now describes the actual defect, and the close is explicit rather than a side effect of clearing in_tunnel.
One nit: the guard tests post_transform_info.entry, but this line dereferences post_transform_info.vc. The invariant does hold today — do_post_transform_open() only creates the entry when vc is non-null and sets entry->vc = vc, and state_common_wait_for_transform_read() nulls both together — but post_transform_info.entry->vc->do_io_close() is the more direct expression, and it's the very pointer cleanup_entry() asserts on (ink_assert(e->vc)) one line later. Either that, or guard on .vc the way the other sites in this file do.
For the record on the other direction: TransformVConnection::do_io_close() early-returns on m_closed != 0, so a double close here is harmless.
| if (post_transform_info.entry != nullptr) { | ||
| post_transform_info.vc->do_io_close(); | ||
| vc_table.cleanup_entry(post_transform_info.entry); | ||
| post_transform_info.entry = nullptr; |
There was a problem hiding this comment.
Still open from the last round — flagging once rather than re-explaining. This leaves post_transform_info.vc non-null with entry == nullptr, and tunnel_handler_post_or_put() checks only .vc before dereferencing .entry.
Leaving .vc set is deliberate and correct (it's what makes transform_cleanup() skip the chain), so the only question is whether tunnel_handler_post_or_put() is reachable after this abort. If you've satisfied yourself that it isn't, a one-line comment saying so would save the next reader the trip.
| p.Command = (f'{sys.executable} {self._partial_post_client} ' | ||
| f'127.0.0.1 {self._ts.Variables.port}') | ||
| p.ReturnCode = 0 | ||
| p.Streams.All += Testers.ContainsExpression('HTTP/1.1', 'Verify client received an HTTP response') |
There was a problem hiding this comment.
This is the one item I'd hold the PR on.
The client's reset path prints HTTP/1.1 connection reset (expected for partial POST) (partial_post_client.py:56), worded such that it satisfies ContainsExpression('HTTP/1.1'). So the tester labelled "Verify client received an HTTP response" passes when no response was received at all — and p.ReturnCode = 0 passes too, because that path returns 0. Someone scanning this file sees a real check where there isn't one.
Please pin down what ATS is actually expected to do here. The origin sends a complete HTTP/1.1 200 OK / Content-Length: 0 before the body finishes, and state_read_server_response_header() sets NO_KEEPALIVE on both sides after the abort — so I'd expect the 200 to be forwarded and then the connection closed, i.e. Testers.ContainsExpression('HTTP/1.1 200 OK', ...) deterministically, matching the other runs in this file.
If it genuinely races between "200 then FIN" and "RST", then say so explicitly: print something that isn't shaped like a status line, assert on it separately, and comment the race. As written, the message and the assertion are engineered to agree with each other regardless of what ATS did.
| except ConnectionError: | ||
| # ATS may reset the connection after responding since the POST | ||
| # body is incomplete. This is acceptable — the important thing | ||
| # is that ATS did not crash. | ||
| print('HTTP/1.1 connection reset (expected for partial POST)') | ||
| return 0 |
There was a problem hiding this comment.
Same point as on the test, from this side: 'HTTP/1.1 connection reset ...' is doing double duty as a human-readable log line and as the token that ContainsExpression('HTTP/1.1') matches over in quick_server.test.py. If a reset really is an acceptable outcome, print something that can't be mistaken for a status line (CONNECTION RESET) and give the test a tester for that string specifically.
Also worth checking: except ConnectionError won't catch a clean FIN — that surfaces as recv() returning b'', which falls through to the return 1 branch below. Since ATS sets NO_KEEPALIVE on this path, a clean close after the response is the more likely outcome, so make sure the branch you expect to hit is the one you're actually asserting on.
|
|
||
| # Partial POST with a request transform plugin: exercises the abort_tunnel() | ||
| # cleanup path for TransformVConnection entries in the vc_table. | ||
| QuickServerTest(abort_request=True, drain_request=False, abort_response_headers=False, use_request_transform=True).run() |
There was a problem hiding this comment.
Two things on this line.
abort_request=True is inert for the transform run — nothing reads _should_abort_request in the use_request_transform branch — yet the generated run name will still print "Aborting request: True". Pass False, or leave the flags that don't apply out of the name.
More important: now that the root cause is correctly identified as a leaked TransformVConnection rather than a use-after-free, this run can't catch a regression on its own — a leak doesn't fail an autest. It only has teeth under ASAN/LSAN. Worth saying that in the comment above, and worth confirming the ASAN autest job actually runs slow_post; otherwise this is a "doesn't crash" smoke test and the leak could come back unnoticed.
| data->output_reader = TSIOBufferReaderAlloc(data->output_buffer); | ||
| Dbg(plugin_ctl, "\tWriting %" PRId64 " bytes on VConn", TSVIONBytesGet(input_vio)); | ||
| data->output_vio = TSVConnWrite(output_conn, contp, data->output_reader, INT64_MAX); | ||
| int64_t nbytes = (request_hdr_mode && TSVIONBytesGet(input_vio) > 0) ? TSVIONBytesGet(input_vio) : INT64_MAX; |
There was a problem hiding this comment.
This is load-bearing and needs a comment, because it reads like an incidental tweak and someone will eventually "simplify" it back.
TSVConnWrite()'s nbytes becomes the terminus write VIO's nbytes, which is what TransformTerminus::handle_event() hands the SM as the TRANSFORM_READ_READY payload. state_request_wait_for_transform_read() then does:
size = *(static_cast<int64_t *>(data));
if (size != INT64_MAX && size >= 0) {
t_state.hdr_info.transform_request_cl = size;
...
} else {
// No content length from the post. This is a no go
event = VC_EVENT_ERROR;
Log::error("Request transformation failed to set content length");
}So with the original INT64_MAX, the request-transform path fails the transaction before the post tunnel is ever set up, and the new test run would never reach abort_tunnel() at all. Something like "a request transform must report a real content length — INT64_MAX makes state_request_wait_for_transform_read() fail the transaction" would make that clear.
Good that the default mode still passes INT64_MAX: tests/gold_tests/tunnel/tunnel_transform.test.py loads this plugin with no arguments, so that path is unchanged.
| case TS_EVENT_HTTP_READ_REQUEST_HDR: | ||
| case TS_EVENT_HTTP_TUNNEL_START: | ||
| Dbg(plugin_ctl, "\tEvent is TS_EVENT_HTTP_TUNNEL_START"); | ||
| Dbg(plugin_ctl, "\tEvent is %d", event); |
There was a problem hiding this comment.
Minor: this drops the readable event name for both cases, and the two modes are now the main thing you'd be using this plugin's debug output to distinguish. Keeping them apart is worth more than sharing the line:
Dbg(plugin_ctl, "\tEvent is %s",
event == TS_EVENT_HTTP_TUNNEL_START ? "TS_EVENT_HTTP_TUNNEL_START" : "TS_EVENT_HTTP_READ_REQUEST_HDR");
Summary
Fix a resource leak in
HttpSM::state_read_server_response_header()whenabort_tunnel()is called while a request transform plugin registered atTS_HTTP_READ_REQUEST_HDR_HOOKis active.Bug
When a POST request has a request transform and the origin server responds
before the full body is forwarded through the transform chain:
state_read_server_response_header()callsabort_tunnel()abort_tunnel()cancels I/O on tunnel producers/consumers and callsreset(), but does not close VCs or clean up vc_table entriespost_transform_info.entrystill references the TransformVConnectionwith
in_tunnel = truecleanup_all()inkill_this()callscleanup_entry(), which skipsdo_io_close()becausein_tunnel == truenever closed — a resource leak on every affected request
The bug only triggers when the request transform is added before the tunnel
starts (e.g. at
TS_HTTP_READ_REQUEST_HDR_HOOK). Transforms added atTS_HTTP_TUNNEL_START_HOOKbecome part of the tunnel chain and are properlycleaned up by
abort_tunnel().An
ink_release_assert(post_transform_info.entry == nullptr)placed afterabort_tunnel()confirms the stale entry on every request that hits thispath. GDB on the resulting core shows:
Fix
After
abort_tunnel(), explicitly close and clean up the orphanedTransformVConnection:
This calls
do_io_close()directly on the transform VC rather thanclearing the
in_tunnelflag, preserving the ownership semantics thatother call sites rely on. With
in_tunnel == true,cleanup_entry()skips its own
do_io_close()and falls through toremove_entry(),so there is no double-close.
post_transform_info.vcis left non-null, which correctly tellstransform_cleanup()inkill_this()that the chain was already closed.Test
Added
post_early_response_transform.test.pywith thenull_transform_requesttest plugin. The test sends a partial POST(
Content-Length: 100000, sends only small chunks slowly) while theorigin responds immediately. This exercises the
abort_tunnel()path withan active request transform, verifying ATS handles the cleanup without
leaking resources.