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 5 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.
| // 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. |
There was a problem hiding this comment.
This comment doesn't match what the code below actually does, and I don't think the crash it describes can happen.
HttpVCTable::cleanup_entry() only calls do_io_close() when in_tunnel == false:
void
HttpVCTable::cleanup_entry(HttpVCTableEntry *e)
{
ink_assert(e->vc);
if (e->in_tunnel == false) {
...
e->vc->do_io_close();
e->vc = nullptr;
}
remove_entry(e);
}At this point post_transform_info.entry->in_tunnel is true. It is set in do_setup_client_request_body_tunnel() (case HttpVC_t::TRANSFORM_VC) and again in setup_transform_to_server_transfer(), and it is cleared only in tunnel_handler_transform_write() — which abort_tunnel() never invokes, since abort_tunnel() doesn't call any consumer/producer handlers. So cleanup_all() in kill_this() falls straight through to remove_entry(), which never dereferences e->vc. There is no do_io_close() on freed memory to prevent.
The two cases:
in_tunnel == true— the real case, and this patch having to clear it is the proof:cleanup_all()never touchede->vc, so the described use-after-free cannot have occurred.in_tunnel == false— hypothetically: the assignment is a no-op, andcleanup_entry()performs the very samedo_io_close()on the very same pointer, just earlier in the call chain. Also not a fix.
That said, I think you have found a real bug, just a different one. abort_tunnel() only issues do_io_read/do_io_write(this, 0, nullptr) on the producers/consumers and then reset(); it never closes the transform chain. kill_tunnel() afterwards finds an empty tunnel, and transform_cleanup() skips the chain because post_transform_info.vc != nullptr. So today TransformVConnection::m_closed is never set, TransformTerminus::handle_event() never reaches delete m_tvc, and the TransformVConnection plus the plugin's transform continuations leak. Forcing a close here does fix that.
So this looks like a fix for a leak / missing teardown rather than for a use-after-free. Could you attach the actual stack trace or ASAN report? If there genuinely is a crash, I want to be sure this isn't masking it rather than fixing it. And either way the comment, the PR description, and the test docstring should be rewritten around the real mechanism.
There was a problem hiding this comment.
Thanks for the review. I think you are right. This is a leak instead of use-after-free.
Unfortunately, since I think this is a resource leak not memory leak, so I cannot find anything with ASAN or LSAN.
There was a problem hiding this comment.
I do have gdb stack trace and hope this can be helpful:
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Core was generated by `/tmp/sb_pr4/post_early_response_transform/ts/bin/traffic_server --bind_stderr /'.
Program terminated with signal SIGABRT, Aborted.
#0 __pthread_kill_implementation (no_tid=0, signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:44
warning: 44 ./nptl/pthread_kill.c: No such file or directory
[Current thread is 1 (Thread 0x7f4ef591e6c0 (LWP 287043))]
#0 __pthread_kill_implementation (no_tid=0, signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:44
#1 __pthread_kill_internal (signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:78
#2 __GI___pthread_kill (threadid=<optimized out>, signo=signo@entry=6) at ./nptl/pthread_kill.c:89
#3 0x00007f4f014a927e in __GI_raise (sig=sig@entry=6) at ../sysdeps/posix/raise.c:26
#4 0x00007f4f0148c8ff in __GI_abort () at ./stdlib/abort.c:79
#5 0x0000632477666236 in ink_abort (message_format=message_format@entry=0x6324787af000 "%s:%d: failed assertion `%s`") at /src/trafficserver_pr/trafficserver/src/tscore/ink_error.cc:99
#6 0x000063247765f9b6 in _ink_assert (expression=expression@entry=0x6324787d9200 "post_transform_info.entry == nullptr", file=file@entry=0x6324787d3480 "/src/trafficserver_pr/trafficserver/src/proxy/http/HttpSM.cc", line=line@entry=2158) at /src/trafficserver_pr/trafficserver/src/tscore/ink_assert.cc:35
#7 0x00006324777cd115 in HttpSM::state_read_server_response_header (this=0x7f4ee73b8000, event=<optimized out>, data=<optimized out>) at /src/trafficserver_pr/trafficserver/src/proxy/http/HttpSM.cc:2158
#8 0x00006324777deec7 in HttpSM::main_handler (this=0x7f4ee73b8000, event=<optimized out>, data=<optimized out>) at /src/trafficserver_pr/trafficserver/src/proxy/http/HttpSM.cc:2880
#9 0x000063247823737a in Continuation::handleEvent (data=0x7f4ee749b5c8, event=100, this=0x7f4ee73b8000) at /src/trafficserver_pr/trafficserver/include/iocore/eventsystem/Continuation.h:228
#10 Continuation::handleEvent (data=0x7f4ee749b5c8, event=100, this=0x7f4ee73b8000) at /src/trafficserver_pr/trafficserver/include/iocore/eventsystem/Continuation.h:224
#11 read_signal_and_update (event=100, vc=0x7f4ee749b1b0) at /src/trafficserver_pr/trafficserver/src/iocore/net/UnixNetVConnection.cc:86
#12 0x00006324782488b8 in UnixNetVConnection::net_read_io (this=0x7f4ee749b1b0, nh=<optimized out>) at /src/trafficserver_pr/trafficserver/src/iocore/net/UnixNetVConnection.cc:600
#13 0x00006324782f51c4 in NetHandler::process_ready_list (this=this@entry=0x7f4efa3a43a0) at /src/trafficserver_pr/trafficserver/src/iocore/net/NetHandler.cc:265
#14 0x00006324782f5da6 in NetHandler::waitForActivity (this=0x7f4efa3a43a0, timeout=<optimized out>) at /src/trafficserver_pr/trafficserver/src/iocore/net/NetHandler.cc:360
#15 0x0000632478443b90 in EThread::execute_regular (this=this@entry=0x7f4efa3a3800) at /src/trafficserver_pr/trafficserver/src/iocore/eventsystem/UnixEThread.cc:326
#16 0x0000632478444552 in EThread::execute (this=0x7f4efa3a3800) at /src/trafficserver_pr/trafficserver/src/iocore/eventsystem/UnixEThread.cc:383
#17 EThread::execute (this=0x7f4efa3a3800) at /src/trafficserver_pr/trafficserver/src/iocore/eventsystem/UnixEThread.cc:360
#18 0x000063247843876e in spawn_thread_internal (a=a@entry=0x50600000faa0) at /src/trafficserver_pr/trafficserver/src/iocore/eventsystem/Thread.cc:75
#19 0x00007f4f027b1a42 in asan_thread_start (arg=0x7f4f0074f000) at ../../../../src/libsanitizer/asan/asan_interceptors.cpp:234
#20 0x00007f4f01500aa4 in start_thread (arg=<optimized out>) at ./nptl/pthread_create.c:447
#21 0x00007f4f0158dc6c in clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:78
#7 0x00006324777cd115 in HttpSM::state_read_server_response_header (this=0x7f4ee73b8000, event=<optimized out>, data=<optimized out>) at /src/trafficserver_pr/trafficserver/src/proxy/http/HttpSM.cc:2158
2158 ink_release_assert(post_transform_info.entry == nullptr);
$1 = {entry = 0x7f4ee73ba950, vc = 0x5130000202c0}
$2 = {vc = 0x5130000202c0, read_buffer = 0x0, write_buffer = 0x0, read_vio = 0x0, write_vio = 0x0, vc_read_handler = NULL, vc_write_handler = NULL, vc_type = HttpVC_t::TRANSFORM_VC, sm = 0x7f4ee73b8000, eos = false, in_tunnel = true}
[ats-develop@b07c50b9e0d7 trafficserver ]$
There was a problem hiding this comment.
The test and stack trace are got in upstream/master.
There was a problem hiding this comment.
I can reproduce the ATS crash with a proprietary plugin under stress, but I can't share that stack trace. This fix can resolve the crash. I will keep current null_transform_request plugin and assert as the way to reproduce the bug. Please let me know if this is not sufficient or anything else we need other than assert and gdb.
There was a problem hiding this comment.
Have updated the title and description
| // 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; |
There was a problem hiding this comment.
Overwriting in_tunnel to coerce a side effect out of cleanup_entry() is a blunt instrument, and it works against the rest of the file. Every other post-transform cleanup site deliberately preserves this flag:
tunnel_handler_post_or_put()assertspost_transform_info.entry->in_tunnel == trueand then callscleanup_entry()specifically so that it will not close, with a comment explaining why.state_common_wait_for_transform_read()andhandle_server_setup_error()likewise callcleanup_entry()without touching the flag.
in_tunnel encodes who owns the VC — the tunnel or the SM. If the intent is "close the transform chain now", it reads much better to say that directly:
post_transform_info.vc->do_io_close();
vc_table.cleanup_entry(post_transform_info.entry);
post_transform_info.entry = nullptr;There's a robustness cost too. The in_tunnel == true check is currently what protects paths where the TVC has already been closed but the entry is still populated — tunnel_handler_transform_write()'s VC_EVENT_ERROR case does c->vc->do_io_close(EHTTP_ERROR) and leaves the entry alone. Clearing the flag unconditionally removes that protection and turns any such path into a genuine use-after-free.
Last thought on placement: abort_tunnel() is the function that leaves the vc_table inconsistent, and kill_tunnel() has the same shape. Patching this one caller leaves the trap set for the next person. A small SM helper (or handling it on the abort path itself) would cover both.
There was a problem hiding this comment.
This makes sense. I have use explicit free for vc.
| 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; |
There was a problem hiding this comment.
Nulling entry while leaving post_transform_info.vc non-null breaks the pairing that two sites rely on — they check .vc and then dereference .entry:
tunnel_handler_post_or_put():if (post_transform_info.vc != nullptr) { ink_assert(post_transform_info.entry->in_tunnel == true); ...; vc_table.cleanup_entry(post_transform_info.entry); }handle_server_setup_error():if (post_transform_info.vc) { ...; vc_table.cleanup_entry(post_transform_info.entry); }
The handle_server_setup_error() one is safe here only incidentally: abort_tunnel() already called reset(), so tunnel.get_consumer(post_transform_info.vc) returns nullptr and the inner guard short-circuits. Worth confirming tunnel_handler_post_or_put() can't be reached after this abort — in a release build the ink_asserts compile out and cleanup_entry(nullptr) faults on e->in_tunnel.
state_common_wait_for_transform_read() already produces this entry == nullptr && vc != nullptr state in the TRANSFORM_FAIL case, so it may well be fine. I'd just rather see it checked than assumed.
There was a problem hiding this comment.
Good catch on the pairing. I traced both sites:
tunnel_handler_post_or_put() — Cannot be reached after this abort_tunnel(). It fires via HTTP_TUNNEL_EVENT_DONE, but abort_tunnel() sets active = false, marks all producers/consumers dead, and calls reset(). The tunnel will never deliver that callback again.
handle_server_setup_error() — Safe as you noted: abort_tunnel() calls reset(), so tunnel.get_consumer(post_transform_info.vc) returns nullptr and the inner guard short-circuits before touching .entry.
The entry == nullptr && vc != nullptr state also matches the existing pattern at line 2929 (tunnel_handler_post_or_put itself does this) and in state_common_wait_for_transform_read()'s TRANSFORM_FAIL case.
So I'll keep .vc non-null to stay consistent with the rest of the file.
| vc_table.cleanup_entry(post_transform_info.entry); | ||
| post_transform_info.entry = nullptr; | ||
| } | ||
| ink_release_assert(post_transform_info.entry == nullptr); |
There was a problem hiding this comment.
This assert is tautological: the block immediately above sets post_transform_info.entry = nullptr, and if the if wasn't taken it was already nullptr. It can never fire. Looks like leftover repro scaffolding — please drop it.
Relatedly, the PR description still says to "uncomment the assert after abort_tunnel()" to reproduce, which no longer matches the diff.
There was a problem hiding this comment.
You're right, removed. The assert was repro scaffolding used to confirm the stale entry via GDB — it's not part of the fix.
| """Verify ATS does not crash when a server replies before receiving the full POST body and a request transform plugin is active. | ||
|
|
||
| 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(). |
There was a problem hiding this comment.
Two things.
First, this docstring restates the "use-after-free in cleanup_all()" mechanism, which I don't think holds — see my comment on HttpSM.cc. Whatever the real root cause turns out to be, this needs to match it.
Second, and more important: does this test fail on master? AuTest would catch a genuine traffic_server crash — MakeATSProcess sets p.ReturnCode = 0 on the ATS process and adds an ExcludesExpression("FATAL:") tester on diags.log — so a real SIGSEGV/SIGABRT does fail the run. But if the pre-patch defect is a leak rather than a crash (which is what I believe it is), this test passes with and without the fix and isn't a regression test at all. Could you post the failing output from master?
I also don't see any CI runs on this branch yet. Worth getting Jenkins green before this goes further.
| _partial_post_client = 'partial_post_client.py' | ||
| _quick_server = 'quick_server.py' | ||
| _init_file = '__init__.py' | ||
| _http_utils = 'http_utils.py' |
There was a problem hiding this comment.
+1 to Copilot on this one: _http_utils is never used — line 94 rebuilds the path inline. Please drop the class attribute.
| p.ReturnCode = 0 | ||
| p.Streams.All += Testers.ContainsExpression('Got response', 'Verify client received a response from ATS') |
There was a problem hiding this comment.
Both of these assertions are vacuous:
- The client's
main()unconditionally doesreturn 0on every path, including thesocket.timeoutandConnectionErrorhandlers, sop.ReturnCode = 0cannot fail. ContainsExpression('Got response', ...)matchesGot response: timeout (server may still be processing)andGot response: connection closedjust as happily as a real response.
So the test currently rests entirely on the implicit "ATS didn't crash" check. Please assert on the actual status line the proxy is expected to return, and have the client exit non-zero when it doesn't get one — compare quick_server.test.py, which checks for HTTP/1.1 200 OK explicitly.
| 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' | ||
| f'\r\n').encode() |
There was a problem hiding this comment.
Minor: none of these f prefixes have placeholders, so plain string literals would do (yapf/flake8 will likely flag them).
More generally, this overlaps slow_post_client.py in the same directory, which already does the "send a POST and don't finish it" dance. The difference that matters is Content-Length vs. Transfer-Encoding: chunked, so a --content-length N --send-bytes M mode there would avoid a third client script in this directory. Your call — the mechanism is different enough that a separate file is defensible.
| typedef struct { | ||
| TSVIO output_vio; | ||
| TSIOBuffer output_buffer; | ||
| TSIOBufferReader output_reader; | ||
| } TransformData; |
There was a problem hiding this comment.
typedef struct { ... } TransformData; is a C idiom; in C++ this is just struct TransformData { ... };.
Also <cstdio> and <cinttypes> (lines 29-30) are unused now that the Dbg() calls are gone.
| static int | ||
| transform_plugin(TSCont /* contp ATS_UNUSED */, TSEvent event, void *edata) | ||
| { | ||
| if (event == TS_EVENT_HTTP_READ_REQUEST_HDR) { |
There was a problem hiding this comment.
Following up on the de-duplication thread above: after the trimming, the only remaining difference from tunnel_transform.cc is this hook point (plus not adding the response transform). The ~70-line transform body is a verbatim copy.
PrepareTestPlugin already forwards plugin_args into plugin.config, so tunnel_transform could take an argument selecting TS_HTTP_READ_REQUEST_HDR_HOOK vs. TS_HTTP_TUNNEL_START_HOOK (and whether to add the response transform), and this file could go away entirely. That's a better outcome than two copies of the same null transform to keep in sync.
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'
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: 10000000, 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.