This is an automated email from the ASF dual-hosted git repository.
bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 16bd59ab4c healthchecks: reference count status file data (#13432)
16bd59ab4c is described below
commit 16bd59ab4ca21f5d0f1a15cfd9d5be22f106d5f0
Author: Brian Neradt <[email protected]>
AuthorDate: Fri Jul 31 17:06:50 2026 -0500
healthchecks: reference count status file data (#13432)
Replaced file data was retired onto a freelist and freed after a
timeout, but its deadline came from a timestamp taken before a blocking
inotify read. That stale deadline let transactions retain references to
data that was already freed. Files exactly 16 KiB long were also
reported as empty because a final zero-byte read overwrote the saved
length.
This holds each immutable snapshot in an atomic shared_ptr. Every
transaction pins its snapshot, so data lives exactly as long as it is
referenced, without a freelist or request-path mutex. This also
preserves the last successful file-read length and adds AuTest coverage
for concurrent replacement and the 16 KiB boundary.
Fixes: #8735
---
plugins/healthchecks/healthchecks.cc | 201 ++++++++++-----------
.../pluginTest/healthchecks/healthchecks.test.py | 54 ++++++
2 files changed, 150 insertions(+), 105 deletions(-)
diff --git a/plugins/healthchecks/healthchecks.cc
b/plugins/healthchecks/healthchecks.cc
index f31a43d0cc..c5da85da9d 100644
--- a/plugins/healthchecks/healthchecks.cc
+++ b/plugins/healthchecks/healthchecks.cc
@@ -28,6 +28,8 @@ limitations under the License.
#include <unistd.h>
#include <inttypes.h>
#include <atomic>
+#include <memory>
+#include <utility>
/* ToDo: Linux specific */
#include <sys/inotify.h>
@@ -42,9 +44,8 @@ static const char SEPARATORS[] = " \t\n";
static DbgCtl dbg_ctl{PLUGIN_NAME};
-#define MAX_PATH_LEN 4096
-#define MAX_BODY_LEN 16384
-#define FREELIST_TIMEOUT 300
+#define MAX_PATH_LEN 4096
+#define MAX_BODY_LEN 16384
/* Directories that we are watching for inotify IN_CREATE events. */
typedef struct HCDirEntry_t {
@@ -53,66 +54,100 @@ typedef struct HCDirEntry_t {
struct HCDirEntry_t *_next; /* Linked list */
} HCDirEntry;
-/* Information about a status file. This is never modified (only replaced, see
HCFileInfo_t) */
-typedef struct HCFileData_t {
- int exists; /* Does this file exist */
- char body[MAX_BODY_LEN]; /* Body from fname. Empty string
means file is missing */
- int b_len; /* Length of data */
- time_t remove; /* Used for deciding when the old
object can be permanently removed */
- struct HCFileData_t *_next; /* Only used when these guys end up
on the freelist */
-} HCFileData;
-
-/* The only thing that should change in this struct is data, atomically
swapping ptrs */
-typedef struct HCFileInfo_t {
- char fname[MAX_PATH_LEN]; /* Filename */
- char *basename; /* The "basename" of the file
*/
- unsigned basename_len = 0; /* The length of the basename
*/
- char path[PATH_NAME_MAX]; /* URL path for this HC */
- int p_len; /* Length of path */
- const char *ok; /* Header for an OK result */
- int o_len; /* Length of OK header */
- const char *miss; /* Header for miss results */
- int m_len; /* Length of miss header */
- std::atomic<HCFileData *> data; /* Holds the current data for
this health check file */
- int wd; /* Watch descriptor */
- HCDirEntry *dir; /* Reference to the directory
this file resides in */
- struct HCFileInfo_t *_next; /* Linked list */
-} HCFileInfo;
+/* Information about a status file. This is never modified (only replaced, see
HCFileInfo) */
+struct HCFileData {
+ int exists = 0; /* Does this file exist */
+ int b_len = 0; /* Length of data */
+ char body[MAX_BODY_LEN] = {}; /* Body from fname. Empty string means file is
missing */
+};
+
+using HCFileDataPtr = std::shared_ptr<HCFileData>;
+
+/* The only thing that should change in this struct is data, which is replaced
(never modified) by
+ the inotify thread. Readers take a reference to the current data via
get_data(), which keeps
+ that snapshot alive for as long as the transaction needs it. */
+struct HCFileInfo {
+ char fname[MAX_PATH_LEN] = {}; /* Filename */
+ char *basename = nullptr; /* The "basename" of the file */
+ unsigned basename_len = 0; /* The length of the basename */
+ char path[PATH_NAME_MAX] = {}; /* URL path for this HC */
+ int p_len = 0; /* Length of path */
+ const char *ok = nullptr; /* Header for an OK result */
+ int o_len = 0; /* Length of OK header */
+ const char *miss = nullptr; /* Header for miss results */
+ int m_len = 0; /* Length of miss header */
+ int wd = 0; /* Watch descriptor */
+ HCDirEntry *dir = nullptr; /* Reference to the directory
this file resides in */
+ HCFileInfo *_next = nullptr; /* Linked list */
+
+ /* Take a reference to the current data for this health check file. */
+ HCFileDataPtr
+ get_data()
+ {
+#if defined(__cpp_lib_atomic_shared_ptr) && __cpp_lib_atomic_shared_ptr >=
201711L
+ return _data.load(std::memory_order_acquire);
+#else
+ return std::atomic_load_explicit(&_data, std::memory_order_acquire);
+#endif
+ }
+
+ /* Replace the current data for this health check file. Snapshots handed out
by get_data() stay
+ valid until their last reference is dropped. */
+ void
+ set_data(HCFileDataPtr data)
+ {
+#if defined(__cpp_lib_atomic_shared_ptr) && __cpp_lib_atomic_shared_ptr >=
201711L
+ _data.store(std::move(data), std::memory_order_release);
+#else
+ std::atomic_store_explicit(&_data, std::move(data),
std::memory_order_release);
+#endif
+ }
+
+private:
+#if defined(__cpp_lib_atomic_shared_ptr) && __cpp_lib_atomic_shared_ptr >=
201711L
+ std::atomic<HCFileDataPtr> _data; /* Holds the current data for this health
check file */
+#else
+ HCFileDataPtr _data; /* Holds the current data for this health check file */
+#endif
+};
/* Global configuration */
HCFileInfo *g_config;
/* State used for the intercept plugin. ToDo: Can this be improved ? */
-typedef struct HCState_t {
- TSVConn net_vc;
- TSVIO read_vio;
- TSVIO write_vio;
+struct HCState {
+ TSVConn net_vc = nullptr;
+ TSVIO read_vio = nullptr;
+ TSVIO write_vio = nullptr;
- TSIOBuffer req_buffer;
- TSIOBuffer resp_buffer;
- TSIOBufferReader resp_reader;
+ TSIOBuffer req_buffer = nullptr;
+ TSIOBuffer resp_buffer = nullptr;
+ TSIOBufferReader resp_reader = nullptr;
- int output_bytes;
+ int output_bytes = 0;
- /* We actually need both here, so that our lock free switches works safely */
- HCFileInfo *info;
- HCFileData *data;
-} HCState;
+ /* We hold a reference to the data so that it cannot be replaced from under
us mid transaction */
+ HCFileInfo *info = nullptr;
+ HCFileDataPtr data;
+};
/* Read / check the status files */
-static void
-reload_status_file(HCFileInfo *info, HCFileData *data)
+static HCFileDataPtr
+load_status_file(HCFileInfo *info)
{
+ auto data = std::make_shared<HCFileData>();
FILE *fd;
- memset(data, 0, sizeof(HCFileData));
if (nullptr != (fd = fopen(info->fname, "r"))) {
data->exists = 1;
- do {
- data->b_len = fread(data->body, 1, MAX_BODY_LEN, fd);
- } while (!feof(fd)); /* Only save the last 16KB of the file ... */
+ size_t bytes_read;
+ while ((bytes_read = fread(data->body, 1, MAX_BODY_LEN, fd)) > 0) {
+ data->b_len = static_cast<int>(bytes_read);
+ }
fclose(fd);
}
+
+ return data;
}
/* Find a HCDirEntry from the linked list */
@@ -198,49 +233,16 @@ event_matches_config(struct inotify_event *event,
HCFileInfo *finfo)
static void *
hc_thread(void *data ATS_UNUSED)
{
- int inotify_fd = inotify_init();
- HCFileData *fl_head = nullptr;
- char buffer[INOTIFY_BUFLEN];
- struct timeval last_free, now;
-
- gettimeofday(&last_free, nullptr);
+ int inotify_fd = inotify_init();
+ char buffer[INOTIFY_BUFLEN];
/* Setup watchers for the directories, these are a one time setup */
setup_watchers(inotify_fd); // This is a leak, but since we enter an
infinite loop this is ok?
while (true) {
- HCFileData *fdata = fl_head, *fdata_prev = nullptr;
-
- gettimeofday(&now, nullptr);
/* Read the inotify events, blocking until we get something */
int len = read(inotify_fd, buffer, INOTIFY_BUFLEN);
- /* The fl_head is a linked list of previously released data entries. They
- are ordered "by time", so once we find one that is scheduled for
deletion,
- we can also delete all entries after it in the linked list. */
- while (fdata) {
- if (now.tv_sec > fdata->remove) {
- /* Now drop off the "tail" from the freelist */
- if (fdata_prev) {
- fdata_prev->_next = nullptr;
- } else {
- fl_head = nullptr;
- }
-
- /* free() everything in the "tail" */
- do {
- HCFileData *next = fdata->_next;
-
- Dbg(dbg_ctl, "Cleaning up entry from freelist");
- TSfree(fdata);
- fdata = next;
- } while (fdata);
- break; /* Stop the loop, there's nothing else left to examine */
- }
- fdata_prev = fdata;
- fdata = fdata->_next;
- }
-
if (len >= 0) {
int i = 0;
@@ -253,9 +255,6 @@ hc_thread(void *data ATS_UNUSED)
finfo = finfo->_next;
}
if (finfo) {
- auto *new_data = TSRalloc<HCFileData>();
- HCFileData *old_data;
-
if (event->mask & (IN_CLOSE_WRITE | IN_ATTRIB)) {
Dbg(dbg_ctl, "Modify file event (%d) on %s", event->mask,
finfo->fname);
} else if (event->mask & (IN_CREATE | IN_MOVED_TO)) {
@@ -267,16 +266,12 @@ hc_thread(void *data ATS_UNUSED)
} else {
Dbg(dbg_ctl, "Unhandled event (%d) on %s", event->mask,
finfo->fname);
}
- /* Load the new data and then swap this atomically */
- memset(new_data, 0, sizeof(HCFileData));
- reload_status_file(finfo, new_data);
- Dbg(dbg_ctl, "Reloaded %s, len == %d, exists == %d", finfo->fname,
new_data->b_len, new_data->exists);
- old_data = finfo->data.exchange(new_data);
+ /* Load the new data and then publish it. The previous data is
released once the last
+ transaction referencing it completes. */
+ auto new_data = load_status_file(finfo);
- /* Add the old data to the head of the freelist */
- old_data->remove = now.tv_sec + FREELIST_TIMEOUT;
- old_data->_next = fl_head;
- fl_head = old_data;
+ Dbg(dbg_ctl, "Reloaded %s, len == %d, exists == %d", finfo->fname,
new_data->b_len, new_data->exists);
+ finfo->set_data(std::move(new_data));
}
/* coverity[ -tainted_data_return] */
i += sizeof(struct inotify_event) + event->len;
@@ -342,10 +337,9 @@ parse_configs(const char *fname)
char *str, *save;
char *ok = nullptr, *miss = nullptr, *mime = nullptr;
- finfo = TSRalloc<HCFileInfo>();
- memset(static_cast<void *>(finfo), 0, sizeof(HCFileInfo));
-
if (fgets(buf, sizeof(buf) - 1, fd)) {
+ finfo = new HCFileInfo();
+
str = strtok_r(buf, SEPARATORS, &save);
int state = 0;
while (nullptr != str) {
@@ -388,9 +382,7 @@ parse_configs(const char *fname)
Dbg(dbg_ctl, "Parsed: %s %s %s %s %s", finfo->path, finfo->fname,
mime, ok, miss);
finfo->ok = gen_header(ok, mime, &finfo->o_len);
finfo->miss = gen_header(miss, mime, &finfo->m_len);
- finfo->data = TSRalloc<HCFileData>();
- memset(finfo->data, 0, sizeof(HCFileData));
- reload_status_file(finfo, finfo->data);
+ finfo->set_data(load_status_file(finfo));
/* Add it the linked list */
Dbg(dbg_ctl, "Adding path=%s to linked list", finfo->path);
@@ -401,7 +393,7 @@ parse_configs(const char *fname)
}
prev_finfo = finfo;
} else {
- TSfree(finfo);
+ delete finfo;
}
}
}
@@ -434,7 +426,7 @@ cleanup(TSCont contp, HCState *my_state)
my_state->net_vc = nullptr;
}
- TSfree(my_state);
+ delete my_state;
TSContDestroy(contp);
}
@@ -567,11 +559,10 @@ health_check_origin(TSCont contp ATS_UNUSED, TSEvent
event ATS_UNUSED, void *eda
TSHttpTxnCntlSet(txnp, TS_HTTP_CNTL_SKIP_REMAPPING, true); /* not strictly
necessary, but speed is everything these days */
/* This is us -- register our intercept */
- icontp = TSContCreate(hc_intercept, TSMutexCreate());
- my_state = TSRalloc<HCState>();
- memset(my_state, 0, sizeof(*my_state));
+ icontp = TSContCreate(hc_intercept, TSMutexCreate());
+ my_state = new HCState();
my_state->info = info;
- my_state->data = info->data;
+ my_state->data = info->get_data();
TSContDataSet(icontp, my_state);
TSHttpTxnIntercept(icontp, txnp);
}
diff --git a/tests/gold_tests/pluginTest/healthchecks/healthchecks.test.py
b/tests/gold_tests/pluginTest/healthchecks/healthchecks.test.py
index 28479e079c..3392f42791 100644
--- a/tests/gold_tests/pluginTest/healthchecks/healthchecks.test.py
+++ b/tests/gold_tests/pluginTest/healthchecks/healthchecks.test.py
@@ -44,6 +44,9 @@ class TestFileChangeBehavior:
self._expect_acme_ssl_404()
self._re_add_acme_ssl()
self._expect_positive_healthchecks()
+ self._expect_full_buffer_acme_body()
+ self._rewrite_acme_while_serving()
+ self._expect_rewritten_acme_body()
def _configure_global_ts(self) -> None:
'''Configure a global Traffic Server instance for the test runs.
@@ -153,6 +156,57 @@ ssl_multicert:
p.Command = 'sleep 1'
p.ReturnCode = 0
+ def _rewrite_acme_while_serving(self) -> None:
+ '''Rewrite the acme file repeatedly while healthcheck requests are in
flight.
+
+ The plugin replaces the health check file data underneath transactions
which may still be
+ reading the previous data. This drives that replacement so that an
ASan enabled build
+ catches the old data being released while it is still referenced.
+ :return: None
+ '''
+ tr = Test.AddTestRun('Rewrite acme while healthchecks are being
served')
+ acme_file = os.path.join(Test.RunDirectory, 'acme')
+ url = f'http://127.0.0.1:{self._ts.Variables.port}/acme'
+
+ # Note that autest runs the command through string.Template, so shell
variables cannot be
+ # used here. The loop is therefore unrolled.
+ commands = []
+ for iteration in range(10):
+ commands.append(f'echo "{CONTENT} {iteration}" > {acme_file};')
+ commands.append('{curl} -s -o /dev/null ' + url + ' &')
+ commands.append('{curl} -s -o /dev/null ' + url + ' &')
+ commands.append('wait')
+
+ tr.MakeCurlCommandMulti(' '.join(commands), ts=self._ts)
+ tr.Processes.Default.ReturnCode = 0
+
+ def _expect_full_buffer_acme_body(self) -> None:
+ '''Verify that a MAX_BODY_LEN-sized file is not reported as empty.
+ :return: None
+ '''
+ tr = Test.AddTestRun('Expect a full-sized healthcheck response body')
+ acme_file = os.path.join(Test.RunDirectory, 'acme')
+ url = f'http://127.0.0.1:{self._ts.Variables.port}/acme'
+ command = (f'dd if=/dev/zero of={acme_file} bs=16384 count=1
2>/dev/null && sleep 1 && ' + '{curl} -s ' + url + ' | wc -c')
+ tr.MakeCurlCommandMulti(command, ts=self._ts)
+ p = tr.Processes.Default
+ p.ReturnCode = 0
+ p.Streams.All += Testers.ContainsExpression('16384', 'Verify the
response contains 16 KiB')
+
+ def _expect_rewritten_acme_body(self) -> None:
+ '''Verify that the most recently written acme content is what gets
served.
+ :return: None
+ '''
+ tr = Test.AddTestRun('Expect the last written acme content in the
response body')
+ acme_file = os.path.join(Test.RunDirectory, 'acme')
+ url = f'http://127.0.0.1:{self._ts.Variables.port}/acme'
+ command = f'echo "{CONTENT} final" > {acme_file} && sleep 1 && ' +
'{curl} -v ' + url
+ tr.MakeCurlCommandMulti(command, ts=self._ts)
+ p = tr.Processes.Default
+ p.ReturnCode = 0
+ p.Streams.All += Testers.ContainsExpression('HTTP/1.1 200', 'Verify
200 response for /acme')
+ p.Streams.All += Testers.ContainsExpression(f'{CONTENT} final',
'Verify the reloaded acme content is served')
+
# Instantiate the test
TestFileChangeBehavior()