From 53f8d620bb718df3d1300c5d7297472ffd5db17c Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:00:09 +0300 Subject: [PATCH 01/60] refactor: migrate to Zend Memory API, add comprehensive tests, fix strtok bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tr_array полностью переведён на emalloc/erealloc/efree. Функции записи фиксированных типов перемещены в заголовок как static inline для устранения накладных расходов на вызов. tr_array_clear теперь только сбрасывает size/position без переаллокации. Исправлен порядок CHECK_ALLOC в init; tr_array_free обнуляет поля. Исправлена ошибка в parse_domain_port_pairs: strdup перед strtok, чтобы не модифицировать исходную строку. test.php разбит на четыре .phpt-теста (settings, timer, stress, tr_array_memory) и удалён. Добавлен C-юнит-тест для parse_domain_port_pairs (8 кейсов) и tr_internal_test (32 теста для tr_array и tr_timer) с заглушкой tests/stubs/php.h, позволяющей компиляцию вне PHP. CI обновлён для запуска make test и всех C-юнит-тестов. --- .github/workflows/build.yml | 12 +- .gitignore | 3 +- .opencode/instructions.md | 34 +++ test.php | 50 ---- tests/.gitignore | 4 + tests/001_settings.phpt | 21 ++ tests/002_timer.phpt | 31 +++ tests/003_stress.phpt | 23 ++ tests/tr_array_memory.phpt | 13 + tests/tr_internal_test.c | 535 ++++++++++++++++++++++++++++++++++++ tests/unit_test.c | 136 +++++++++ trochilidae/tr_array.c | 47 +--- trochilidae/tr_array.h | 43 ++- trochilidae/tr_network.c | 7 +- 14 files changed, 854 insertions(+), 105 deletions(-) create mode 100644 .opencode/instructions.md delete mode 100755 test.php create mode 100644 tests/.gitignore create mode 100644 tests/001_settings.phpt create mode 100644 tests/002_timer.phpt create mode 100644 tests/003_stress.phpt create mode 100644 tests/tr_array_memory.phpt create mode 100644 tests/tr_internal_test.c create mode 100644 tests/unit_test.c diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 432976c..0892a6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,5 +25,13 @@ jobs: run: ./configure --enable-trochilidae - name: make run: make - - name: run test.php - run: php -d extension=`pwd`/modules/trochilidae.so -f test.php + - name: make test + run: make test + - name: Compile unit tests + run: gcc -I/usr/include/php -I/usr/include/php/main -I/usr/include/php/Zend -I/usr/include/php/TSRM -I/usr/include/php/ext -I. -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test + - name: Run unit tests + run: ./tests/unit_test + - name: Compile internal unit tests (tr_array, tr_timer) + run: gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm + - name: Run internal unit tests + run: ./tests/tr_internal_test diff --git a/.gitignore b/.gitignore index d3a4ab2..5054038 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ !test.php !/trochilidae !package.xml -!Dockerfile \ No newline at end of file +!Dockerfile +!.opencode \ No newline at end of file diff --git a/.opencode/instructions.md b/.opencode/instructions.md new file mode 100644 index 0000000..f2989f1 --- /dev/null +++ b/.opencode/instructions.md @@ -0,0 +1,34 @@ +# Project Guidelines: php_trochilidae + +This project is a C-based PHP extension for metrics collection. + +## Project Structure +- `trochilidae/`: Contains core functional sub-components. + - `tr_array.c/h`: Buffer and array manipulation (binary serialization). + - `tr_network.c/h`: TCP/UDP socket communication. + - `tr_timer.c/h`: High-resolution timers and Zend resource management. + - `tr_hooks.c`: Hooks for SAPI integration (intercepting output/execution). + - `utils.c/h`: Helper functions and utility macros. +- `tests/`: Contains `.phpt` files for integration testing. + +## Core Conventions +- **Naming:** + - Internal functions: `tr_` prefix. + - PHP-exposed functions: `trochilidae_` prefix. +- **State Management:** Use `ZEND_DECLARE_MODULE_GLOBALS` and access globals via the `TR_G()` macro. +- **Resources:** Use Zend resource registration (`zend_register_list_destructors_ex`) for automated lifecycle management. +- **Performance:** Maintain low overhead; use custom binary serialization over JSON where possible. +- **Error Handling:** Use `php_error_docref`. + +## Build & Test Workflow +1. **Configuration:** `phpize && ./configure --enable-trochilidae` +2. **Compilation:** `make` +3. **PHP tests:** `make test` +4. **C unit tests (network):** + `gcc -I/usr/include/php -I/usr/include/php/main -I/usr/include/php/Zend -I/usr/include/php/TSRM -I/usr/include/php/ext -I. -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test && ./tests/unit_test` +5. **C unit tests (internal — tr_array, tr_timer):** + `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/tr_internal_test.c -o tests/tr_internal_test -lm && ./tests/tr_internal_test` + *Note:* These use `tests/stubs/php.h` to mock the Zend API outside of PHP context. + +## Stubs +- `tests/stubs/php.h`: Minimal stub for `emalloc`/`erealloc`/`efree`/`estrdup` and `zend_resource` — used by `tr_internal_test.c` to compile without the real PHP headers. diff --git a/test.php b/test.php deleted file mode 100755 index 69398ac..0000000 --- a/test.php +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env php - +--INI-- +trochilidae.enabled=0 +--FILE-- + +--EXPECT-- +trochilidae.enabled: 0 +trochilidae.enabled: 1 +trochilidae.server_list: localhost +trochilidae.server_list: localhost,192.168.1.2:999 diff --git a/tests/002_timer.phpt b/tests/002_timer.phpt new file mode 100644 index 0000000..a13eea5 --- /dev/null +++ b/tests/002_timer.phpt @@ -0,0 +1,31 @@ +--TEST-- +Check timer functionality +--SKIPIF-- + +--FILE-- + +--EXPECTF-- +trochilidae_timer_start +bool(true) +trochilidae_timer_start +bool(true) +bool(true) diff --git a/tests/003_stress.phpt b/tests/003_stress.phpt new file mode 100644 index 0000000..3237b1e --- /dev/null +++ b/tests/003_stress.phpt @@ -0,0 +1,23 @@ +--TEST-- +Check hostname setting and reset/flush functionality +--SKIPIF-- + +--FILE-- + +--EXPECT-- +Hostname set successfully +OK diff --git a/tests/tr_array_memory.phpt b/tests/tr_array_memory.phpt new file mode 100644 index 0000000..d87a8f8 --- /dev/null +++ b/tests/tr_array_memory.phpt @@ -0,0 +1,13 @@ +--TEST-- +Check tr_array functionality with new memory allocation +--SKIPIF-- + +--FILE-- + +--EXPECT-- +Extension loaded successfully. diff --git a/tests/tr_internal_test.c b/tests/tr_internal_test.c new file mode 100644 index 0000000..a562ed8 --- /dev/null +++ b/tests/tr_internal_test.c @@ -0,0 +1,535 @@ +#include +#include +#include +#include +#include + +/* This test is compiled WITHOUT real PHP headers. + Instead we use tests/stubs/php.h which provides malloc-based + substitutes for emalloc/erealloc/efree/estrdup and a minimal + zend_resource definition. This lets us unit-test the internal + C code (tr_array, tr_timer) without booting PHP. */ + +/* Include the actual source-under-test. + The stub php.h intercepts #include "php.h" before the real one. */ +#include "trochilidae/tr_array.h" +#include "trochilidae/tr_timer.h" + +/* Pull in the implementations */ +#include "trochilidae/tr_array.c" +#include "trochilidae/tr_timer.c" + +/* ----------------------------------------------------------- */ +/* Test helpers */ +/* ----------------------------------------------------------- */ + +static int tests = 0; +static int passed = 0; + +#define TEST(name) do { printf(" %-55s ", name); tests++; } while (0) +#define PASS() do { puts("PASS"); passed++; } while (0) +#define FAIL(msg) do { printf("FAIL: %s\n", msg); } while (0) +#define CHECK(cond, msg) do { if (!(cond)) { FAIL(msg); return; } } while (0) +#define CHECK_INT(a,op,b,msg) do { if (!((a) op (b))) { FAIL(msg); return; } } while (0) + +/* ----------------------------------------------------------- */ +/* tr_array tests */ +/* ----------------------------------------------------------- */ + +static void test_arr_init_default() { + TEST("tr_array_init with default capacity"); + struct tr_array a; + tr_array_init(&a, 0); + CHECK(a.data != NULL, "data is NULL"); + CHECK(a.capacity == DEFAULT_CAPACITY, "capacity != DEFAULT_CAPACITY"); + CHECK(a.init_capacity == DEFAULT_CAPACITY, "init_capacity mismatch"); + CHECK(a.size == 0, "size != 0"); + CHECK(a.position == 0, "position != 0"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_init_custom() { + TEST("tr_array_init with custom capacity"); + struct tr_array a; + tr_array_init(&a, 128); + CHECK(a.data != NULL, "data is NULL"); + CHECK(a.capacity == 128, "capacity != 128"); + CHECK(a.init_capacity == 128, "init_capacity mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_init_null() { + TEST("tr_array_init with NULL self (no-op)"); + tr_array_init(NULL, 0); + PASS(); +} + +static void test_arr_write_byte() { + TEST("tr_array_write_byte"); + struct tr_array a; + tr_array_init(&a, 16); + unsigned char v = 0xAB; + tr_array_write_byte(&a, &v); + CHECK(a.size == 1, "size != 1"); + CHECK(a.position == 1, "position != 1"); + CHECK(a.data[0] == 0xAB, "data[0] wrong value"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_short() { + TEST("tr_array_write_short"); + struct tr_array a; + tr_array_init(&a, 16); + unsigned short v = 0x1234; + tr_array_write_short(&a, &v); + CHECK(a.size == 2, "size != 2"); + CHECK(memcmp(a.data, &v, 2) == 0, "data mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_word() { + TEST("tr_array_write_word"); + struct tr_array a; + tr_array_init(&a, 16); + unsigned int v = 0xDEADBEEF; + tr_array_write_word(&a, &v); + CHECK(a.size == 4, "size != 4"); + CHECK(memcmp(a.data, &v, 4) == 0, "data mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_long() { + TEST("tr_array_write_long"); + struct tr_array a; + tr_array_init(&a, 16); + unsigned long long v = 0x0123456789ABCDEFULL; + tr_array_write_long(&a, &v); + CHECK(a.size == 8, "size != 8"); + CHECK(memcmp(a.data, &v, 8) == 0, "data mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_data() { + TEST("tr_array_write_data"); + struct tr_array a; + tr_array_init(&a, 16); + const char *payload = "hello"; + tr_array_write_data(&a, payload, 5); + CHECK(a.size == 5, "size != 5"); + CHECK(memcmp(a.data, payload, 5) == 0, "data mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_data_at_pos() { + TEST("tr_array_write_data_at_pos"); + struct tr_array a; + tr_array_init(&a, 16); + const char *payload = "world"; + tr_array_write_data(&a, "xxxxx", 5); + tr_array_write_data_at_pos(&a, 0, payload, 5); + CHECK(memcmp(a.data, "world", 5) == 0, "data at pos mismatch"); + CHECK(a.position == 5, "position changed by at_pos write"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_string() { + TEST("tr_array_write_string"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_string(&a, "abc"); + /* format: word(length) + bytes */ + unsigned int exp_len = 3; + CHECK(a.size == 4 + 3, "bad total size"); + CHECK(memcmp(a.data, &exp_len, 4) == 0, "length word mismatch"); + CHECK(memcmp(a.data + 4, "abc", 3) == 0, "string data mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_string_null() { + TEST("tr_array_write_string(NULL)"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_string(&a, NULL); + /* writes word(0) */ + unsigned int exp = 0; + CHECK(a.size == 4, "expected 4 bytes for word(0)"); + CHECK(memcmp(a.data, &exp, 4) == 0, "word should be 0"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_string_size() { + TEST("tr_array_write_string_size"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_string_size(&a, "xyz", 3); + unsigned int exp_len = 3; + CHECK(a.size == 4 + 3, "bad total size"); + CHECK(memcmp(a.data, &exp_len, 4) == 0, "length word mismatch"); + CHECK(memcmp(a.data + 4, "xyz", 3) == 0, "string data mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_set_position() { + TEST("tr_array_set_position / get"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_byte(&a, (byte[]){0x01}); + tr_array_set_position(&a, 0); + CHECK(tr_array_get_position(&a) == 0, "position should be 0"); + CHECK(tr_array_get_size(&a) == 1, "size should be 1"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_clear() { + TEST("tr_array_clear resets size and position"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_byte(&a, (byte[]){0x01}); + tr_array_write_byte(&a, (byte[]){0x02}); + CHECK(a.size == 2, "size should be 2 before clear"); + tr_array_clear(&a); + CHECK(a.size == 0, "size != 0 after clear"); + CHECK(a.position == 0, "position != 0 after clear"); + CHECK(a.data != NULL, "data should still be allocated"); + CHECK(a.capacity == 64, "capacity should be preserved"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_ensure_capacity_growth() { + TEST("tr_array_ensure_capacity doubles buffer"); + struct tr_array a; + tr_array_init(&a, 8); + size_t old_cap = a.capacity; + /* write enough to trigger realloc */ + char buf[20]; + memset(buf, 0xFF, 20); + tr_array_write_data(&a, buf, old_cap + 1); + CHECK(a.capacity >= old_cap * 2, "capacity should at least double"); + CHECK(a.size == old_cap + 1, "size mismatch after growth"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_free_resets() { + TEST("tr_array_free resets fields"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_free(&a); + CHECK(a.data == NULL, "data should be NULL"); + CHECK(a.size == 0, "size should be 0"); + CHECK(a.capacity == 0, "capacity should be 0"); + PASS(); +} + +static void test_arr_write_data_zero() { + TEST("tr_array_write_data with 0 bytes (no-op)"); + struct tr_array a; + tr_array_init(&a, 16); + size_t old_pos = a.position; + tr_array_write_data(&a, "ignored", 0); + CHECK(a.position == old_pos, "position changed"); + CHECK(a.size == old_pos, "size changed"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_ensure_capacity_zero() { + TEST("tr_array_ensure_capacity(0) is no-op"); + struct tr_array a; + tr_array_init(&a, 16); + size_t old_cap = a.capacity; + tr_array_ensure_capacity(&a, 0); + CHECK(a.capacity == old_cap, "capacity changed"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_string_size_null() { + TEST("tr_array_write_string_size(NULL, 0) writes word(0)"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_string_size(&a, NULL, 0); + unsigned int exp = 0; + CHECK(a.size == 4, "expected 4 bytes"); + CHECK(memcmp(a.data, &exp, 4) == 0, "word should be 0"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_write_tv() { + TEST("tr_array_write_tv (timeval)"); + struct tr_array a; + tr_array_init(&a, 64); + struct timeval tv = { .tv_sec = 12345, .tv_usec = 67890 }; + tr_array_write_tv(&a, &tv); + CHECK(a.size == 8, "size should be 4+4=8"); + uint32_t sec; + memcpy(&sec, a.data, 4); + CHECK(sec == 12345, "tv_sec mismatch"); + uint32_t usec; + memcpy(&usec, a.data + 4, 4); + CHECK(usec == 67890, "tv_usec mismatch"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_consecutive_writes_trigger_multiple_growth() { + TEST("tr_array consecutive writes trigger multiple reallocs"); + struct tr_array a; + tr_array_init(&a, 4); + /* Write small amounts repeatedly to force several doublings */ + char buf[256]; + memset(buf, 0xAA, sizeof(buf)); + int i; + for (i = 0; i < 10; i++) { + tr_array_write_data(&a, buf, 20); + } + CHECK(a.size == 200, "size should be 200"); + CHECK(a.capacity >= 256, "capacity should have grown to >=256"); + CHECK(a.init_capacity == 4, "init_capacity should remain 4"); + /* verify content is intact */ + for (i = 0; i < 10; i++) { + CHECK(memcmp(a.data + i * 20, buf, 20) == 0, "data corrupted after realloc"); + } + tr_array_free(&a); + PASS(); +} + +static void test_arr_mixed_writes() { + TEST("tr_array mixed write_byte/short/word/long"); + struct tr_array a; + tr_array_init(&a, 64); + byte b = 0xBB; + short s = 0x1122; + uint32_t w = 0xAABBCCDD; + uint64_t l = 0x0102030405060708ULL; + + tr_array_write_byte (&a, &b); + tr_array_write_short(&a, &s); + tr_array_write_word (&a, &w); + tr_array_write_long (&a, &l); + + size_t pos = 0; + CHECK(a.data[pos] == b, "byte mismatch"); pos += 1; + CHECK(memcmp(a.data + pos, &s, 2) == 0, "short mismatch"); pos += 2; + CHECK(memcmp(a.data + pos, &w, 4) == 0, "word mismatch"); pos += 4; + CHECK(memcmp(a.data + pos, &l, 8) == 0, "long mismatch"); + tr_array_free(&a); + PASS(); +} + +/* ----------------------------------------------------------- */ +/* tr_timer tests */ +/* ----------------------------------------------------------- */ + +static void test_tmr_new() { + TEST("tr_timer_new sets name and zeroes counters"); + TrTimer *t = tr_timer_new("test_timer"); + CHECK(t != NULL, "timer is NULL"); + CHECK(strcmp(t->name, "test_timer") == 0, "name mismatch"); + CHECK(t->startCount == 0, "startCount != 0"); + CHECK(t->stopCount == 0, "stopCount != 0"); + CHECK(t->executionTime.tv_sec == 0, "executionTime.tv_sec != 0"); + CHECK(t->executionTime.tv_usec == 0, "executionTime.tv_usec != 0"); + CHECK(t->totalExecutionTime.tv_sec == 0, "total.tv_sec != 0"); + CHECK(t->totalExecutionTime.tv_usec == 0, "total.tv_usec != 0"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_start_stop_once() { + TEST("tr_timer_start/stop once"); + TrTimer *t = tr_timer_new("once"); + tr_timer_start(t); + struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 }; /* 1ms */ + nanosleep(&ts, NULL); + tr_timer_stop(t); + CHECK(t->startCount == 1, "startCount != 1"); + CHECK(t->stopCount == 1, "stopCount != 1"); + CHECK(t->totalExecutionTime.tv_sec == 0, "total sec should be 0 for 1ms"); + CHECK(t->totalExecutionTime.tv_usec >= 500, "total usec should be >= 500"); + CHECK(t->totalExecutionTime.tv_usec < 10000, "total usec should be < 10ms"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_auto_stop_on_restart() { + TEST("tr_timer_start auto-stops before restart"); + TrTimer *t = tr_timer_new("auto_stop"); + tr_timer_start(t); + struct timespec ts = { .tv_sec = 0, .tv_nsec = 500000 }; /* 0.5ms */ + nanosleep(&ts, NULL); + /* second start should auto-stop the first lap */ + tr_timer_start(t); + CHECK(t->startCount == 2, "startCount != 2"); + CHECK(t->stopCount == 1, "stopCount != 1 (auto-stop)"); + CHECK(t->totalExecutionTime.tv_usec > 0, "total should have accumulated"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_stop_noop_if_not_running() { + TEST("tr_timer_stop is no-op when not running"); + TrTimer *t = tr_timer_new("noop"); + tr_timer_stop(t); + CHECK(t->startCount == 0, "startCount changed"); + CHECK(t->stopCount == 0, "stopCount changed"); + CHECK(t->totalExecutionTime.tv_sec == 0, "total changed"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_free_null_name() { + TEST("tr_timer_free does not crash"); + TrTimer *t = tr_timer_new("freeme"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_execution_time_is_delta_after_stop() { + TEST("tr_timer executionTime holds delta after stop"); + TrTimer *t = tr_timer_new("delta"); + tr_timer_start(t); + struct timespec ts = { .tv_sec = 0, .tv_nsec = 2000000 }; /* 2ms */ + nanosleep(&ts, NULL); + tr_timer_stop(t); + /* After stop, executionTime = last lap duration */ + CHECK(t->executionTime.tv_sec == 0, "lap sec should be 0"); + CHECK(t->executionTime.tv_usec >= 1000, "lap usec should be >= 1000"); + CHECK(t->executionTime.tv_usec < 20000, "lap usec should be < 20ms"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_stop_twice() { + TEST("tr_timer_stop called twice (second is no-op)"); + TrTimer *t = tr_timer_new("double_stop"); + tr_timer_start(t); + struct timespec ts = { .tv_sec = 0, .tv_nsec = 500000 }; + nanosleep(&ts, NULL); + tr_timer_stop(t); + long first_total = t->totalExecutionTime.tv_usec; + tr_timer_stop(t); + CHECK(t->stopCount == 1, "stopCount should still be 1"); + CHECK(t->totalExecutionTime.tv_usec == first_total, "total should not change"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_two_independent_timers() { + TEST("two independent timers do not interfere"); + TrTimer *a = tr_timer_new("timer_a"); + TrTimer *b = tr_timer_new("timer_b"); + struct timespec ts_a = { .tv_sec = 0, .tv_nsec = 1000000 }; + struct timespec ts_b = { .tv_sec = 0, .tv_nsec = 2000000 }; + + tr_timer_start(a); + nanosleep(&ts_a, NULL); + tr_timer_stop(a); + + tr_timer_start(b); + nanosleep(&ts_b, NULL); + tr_timer_stop(b); + + CHECK(a->startCount == 1, "a startCount"); + CHECK(b->startCount == 1, "b startCount"); + CHECK(a->totalExecutionTime.tv_usec >= 300, "a should be ~1ms"); + CHECK(a->totalExecutionTime.tv_usec < 10000, "a sanity check"); + CHECK(b->totalExecutionTime.tv_usec >= 800, "b should be ~2ms"); + CHECK(b->totalExecutionTime.tv_usec < 20000, "b sanity check"); + /* b should have taken roughly twice as long as a */ + CHECK(b->totalExecutionTime.tv_usec > a->totalExecutionTime.tv_usec, + "b should be slower than a"); + + tr_timer_free(a); + tr_timer_free(b); + PASS(); +} + +static void test_tmr_long_running() { + TEST("tr_timer accumulates tv_sec for long runs"); + TrTimer *t = tr_timer_new("long"); + struct timespec ts = { .tv_sec = 1, .tv_nsec = 0 }; + tr_timer_start(t); + nanosleep(&ts, NULL); + tr_timer_stop(t); + CHECK(t->totalExecutionTime.tv_sec >= 1, "total sec should be >= 1"); + CHECK(t->totalExecutionTime.tv_sec <= 2, "total sec sanity"); + tr_timer_free(t); + PASS(); +} + +static void test_tmr_accumulate() { + TEST("tr_timer accumulates across multiple start/stop"); + TrTimer *t = tr_timer_new("accumulate"); + struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000 }; + int i; + for (i = 0; i < 3; i++) { + tr_timer_start(t); + nanosleep(&ts, NULL); + tr_timer_stop(t); + } + CHECK(t->startCount == 3, "startCount != 3"); + CHECK(t->stopCount == 3, "stopCount != 3"); + CHECK(t->totalExecutionTime.tv_usec >= 1500, "total should be sum of 3 laps"); + tr_timer_free(t); + PASS(); +} + +/* ----------------------------------------------------------- */ +/* Main */ +/* ----------------------------------------------------------- */ + +int main() { + puts("=== tr_array tests ==="); + test_arr_init_default(); + test_arr_init_custom(); + test_arr_init_null(); + test_arr_write_byte(); + test_arr_write_short(); + test_arr_write_word(); + test_arr_write_long(); + test_arr_write_data(); + test_arr_write_data_at_pos(); + test_arr_write_string(); + test_arr_write_string_null(); + test_arr_write_string_size(); + test_arr_set_position(); + test_arr_clear(); + test_arr_ensure_capacity_growth(); + test_arr_write_data_zero(); + test_arr_ensure_capacity_zero(); + test_arr_write_string_size_null(); + test_arr_write_tv(); + test_arr_consecutive_writes_trigger_multiple_growth(); + test_arr_free_resets(); + test_arr_mixed_writes(); + + puts("\n=== tr_timer tests ==="); + test_tmr_new(); + test_tmr_start_stop_once(); + test_tmr_auto_stop_on_restart(); + test_tmr_stop_noop_if_not_running(); + test_tmr_stop_twice(); + test_tmr_execution_time_is_delta_after_stop(); + test_tmr_free_null_name(); + test_tmr_accumulate(); + test_tmr_two_independent_timers(); + test_tmr_long_running(); + + printf("\n=== Results: %d/%d passed ===\n", passed, tests); + return passed == tests ? 0 : 1; +} diff --git a/tests/unit_test.c b/tests/unit_test.c new file mode 100644 index 0000000..91128be --- /dev/null +++ b/tests/unit_test.c @@ -0,0 +1,136 @@ +#include +#include +#include +#include +#include "../trochilidae/tr_network.h" + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST(name) do { printf(" TEST: %s ... ", name); } while(0) +#define PASS() do { printf("PASS\n"); tests_passed++; } while(0) +#define FAIL(msg) do { printf("FAIL: %s\n", msg); tests_failed++; } while(0) +#define ASSERT(cond, msg) do { if (!(cond)) { FAIL(msg); return; } } while(0) + +static void test_normal_case() { + TEST("normal case with mixed ports"); + char input[] = "example.com:80,localhost:3000,another.net"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 3, "expected 3 pairs"); + ASSERT(strcmp(pairs[0].domain, "example.com") == 0, "domain[0] mismatch"); + ASSERT(pairs[0].port == 80, "port[0] mismatch"); + ASSERT(strcmp(pairs[1].domain, "localhost") == 0, "domain[1] mismatch"); + ASSERT(pairs[1].port == 3000, "port[1] mismatch"); + ASSERT(strcmp(pairs[2].domain, "another.net") == 0, "domain[2] mismatch"); + ASSERT(pairs[2].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port[2] should be default"); + free(pairs); + PASS(); +} + +static void test_single_domain_without_port() { + TEST("single domain without port"); + char input[] = "my-host.local"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 1, "expected 1 pair"); + ASSERT(strcmp(pairs[0].domain, "my-host.local") == 0, "domain mismatch"); + ASSERT(pairs[0].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port should be default"); + free(pairs); + PASS(); +} + +static void test_single_domain_with_port() { + TEST("single domain with port"); + char input[] = "myserver:9090"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 1, "expected 1 pair"); + ASSERT(strcmp(pairs[0].domain, "myserver") == 0, "domain mismatch"); + ASSERT(pairs[0].port == 9090, "port should be 9090"); + free(pairs); + PASS(); +} + +static void test_empty_string() { + TEST("empty string"); + char input[] = ""; + int numPairs = 1; // set to non-zero to ensure it gets reset + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(pairs != NULL, "should return non-NULL pointer"); + ASSERT(numPairs == 0, "expected 0 pairs"); + free(pairs); + PASS(); +} + +static void test_multiple_without_ports() { + TEST("multiple domains without ports"); + char input[] = "alpha,beta,gamma"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 3, "expected 3 pairs"); + ASSERT(strcmp(pairs[0].domain, "alpha") == 0, "domain[0] mismatch"); + ASSERT(pairs[0].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port[0] default"); + ASSERT(strcmp(pairs[1].domain, "beta") == 0, "domain[1] mismatch"); + ASSERT(pairs[1].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port[1] default"); + ASSERT(strcmp(pairs[2].domain, "gamma") == 0, "domain[2] mismatch"); + ASSERT(pairs[2].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port[2] default"); + free(pairs); + PASS(); +} + +static void test_mixed_with_and_without_port() { + TEST("mixed with and without ports"); + char input[] = "secured:443,plain,admin:8080"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 3, "expected 3 pairs"); + ASSERT(strcmp(pairs[0].domain, "secured") == 0, "domain[0] mismatch"); + ASSERT(pairs[0].port == 443, "port[0] should be 443"); + ASSERT(strcmp(pairs[1].domain, "plain") == 0, "domain[1] mismatch"); + ASSERT(pairs[1].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port[1] default"); + ASSERT(strcmp(pairs[2].domain, "admin") == 0, "domain[2] mismatch"); + ASSERT(pairs[2].port == 8080, "port[2] should be 8080"); + free(pairs); + PASS(); +} + +static void test_invalid_port_falls_back_to_default() { + TEST("invalid port falls back to default"); + char input[] = "host:notaport"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 1, "expected 1 pair"); + ASSERT(strcmp(pairs[0].domain, "host") == 0, "domain mismatch"); + ASSERT(pairs[0].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port should be default"); + free(pairs); + PASS(); +} + +static void test_empty_port_value() { + TEST("domain with colon but no port (empty port)"); + char input[] = "host:"; + int numPairs = 0; + DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); + ASSERT(numPairs == 1, "expected 1 pair"); + ASSERT(strcmp(pairs[0].domain, "host") == 0, "domain mismatch"); + ASSERT(pairs[0].port == PHP_TROCHILIDAE_SERVER_DEFAULT_PORT, "port should be default"); + free(pairs); + PASS(); +} + +int main() { + printf("Running extended unit tests for parse_domain_port_pairs...\n\n"); + + test_normal_case(); + test_single_domain_without_port(); + test_single_domain_with_port(); + test_empty_string(); + test_multiple_without_ports(); + test_mixed_with_and_without_port(); + test_invalid_port_falls_back_to_default(); + test_empty_port_value(); + + printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/trochilidae/tr_array.c b/trochilidae/tr_array.c index 4b385bc..9e179cd 100644 --- a/trochilidae/tr_array.c +++ b/trochilidae/tr_array.c @@ -2,6 +2,7 @@ // Created by mlex on 09.12.2024. // +#include "php.h" #include "tr_array.h" @@ -13,12 +14,11 @@ void tr_array_init(struct tr_array *self, size_t capacity) { capacity = DEFAULT_CAPACITY; } + self->data = (byte *)emalloc(sizeof(byte) * capacity); + CHECK_ALLOC(self->data); self->init_capacity = capacity; self->capacity = capacity; - self->data = malloc(sizeof(byte) * capacity); tr_array_clear(self); - //fprintf(stderr, "tr_array_init: %zu\n", sizeof(byte) * capacity); - CHECK_ALLOC(self->data); } size_t tr_array_get_size(const struct tr_array *self) { @@ -34,21 +34,17 @@ void tr_array_set_position(struct tr_array *self, size_t position) { } void tr_array_clear(struct tr_array *self) { - if (self->capacity > self->init_capacity) { - free(self->data); - self->capacity = self->init_capacity; - self->data = (byte *)malloc(sizeof(byte) * self->capacity); - CHECK_ALLOC(self->data); - } else { - memset(self->data, 0, self->capacity); - } - self->size = 0; self->position = 0; } -void tr_array_free(const struct tr_array *self) { - free(self->data); +void tr_array_free(struct tr_array *self) { + efree(self->data); + self->data = NULL; + self->size = 0; + self->position = 0; + self->capacity = 0; + self->init_capacity = 0; } void tr_array_ensure_capacity(struct tr_array *self, const size_t additional_size) { @@ -62,9 +58,8 @@ void tr_array_ensure_capacity(struct tr_array *self, const size_t additional_siz if (new_capacity < required_capacity) { new_capacity = required_capacity; } - //fprintf(stderr, "tr_array_ensure_capacity: %zu\n", new_capacity); - byte *new_data = realloc(self->data, sizeof(byte) * new_capacity); + byte *new_data = (byte *)erealloc(self->data, sizeof(byte) * new_capacity); CHECK_ALLOC(new_data); self->data = new_data; @@ -101,26 +96,6 @@ void tr_array_write_string(struct tr_array *self, const char *string) { tr_array_write_string_size(self, string, strlen(string)); } -// Запись одного байта -void tr_array_write_byte(struct tr_array *self, const void *c) { - tr_array_write_data(self, c, 1); - } - -// Запись short (2 байта) -void tr_array_write_short(struct tr_array *self, const void *c) { - tr_array_write_data(self, c, 2); - } - -// Запись word (4 байта) -void tr_array_write_word(struct tr_array *self, const void *c) { - tr_array_write_data(self, c, 4); - } - -// Запись long (8 байт) -void tr_array_write_long(struct tr_array *self, const void *c) { - tr_array_write_data(self, c, 8); - } - void tr_array_write_tv(struct tr_array *self, struct timeval *tv) { tr_array_write_word(self, &tv->tv_sec); tr_array_write_word(self, &tv->tv_usec); diff --git a/trochilidae/tr_array.h b/trochilidae/tr_array.h index f6d4d94..f249656 100644 --- a/trochilidae/tr_array.h +++ b/trochilidae/tr_array.h @@ -38,7 +38,7 @@ struct tr_array { extern void tr_array_init(struct tr_array *self, size_t capacity); extern void tr_array_clear(struct tr_array *self); -extern void tr_array_free(const struct tr_array *self); +extern void tr_array_free(struct tr_array *self); extern size_t tr_array_get_position(const struct tr_array *self); extern void tr_array_set_position(struct tr_array *self, size_t position); extern size_t tr_array_get_size(const struct tr_array *self); @@ -59,19 +59,34 @@ extern void tr_array_write_data_at_pos(struct tr_array *self, size_t pos, const */ extern void tr_array_write_string(struct tr_array *self, const char *string); extern void tr_array_write_string_size(struct tr_array *self, const char *string, const size_t string_size); -extern void tr_array_write_byte(struct tr_array *self, const void *c); -/** - * @brief Write a short value(2 bytes) to the array - */ -extern void tr_array_write_short(struct tr_array *self, const void *c); -/** - * @brief Write a word value (4 bytes) to the array - */ -extern void tr_array_write_word(struct tr_array *self, const void *c); -/** - * @brief Write a long value (8 bytes) to the array - */ -extern void tr_array_write_long(struct tr_array *self, const void *c); extern void tr_array_write_tv(struct tr_array *self, struct timeval *tv); +static inline void tr_array_write_byte(struct tr_array *self, const void *c) { + tr_array_ensure_capacity(self, 1); + self->data[self->position] = *(const byte *)c; + self->position += 1; + self->size += 1; +} + +static inline void tr_array_write_short(struct tr_array *self, const void *c) { + tr_array_ensure_capacity(self, 2); + memcpy(&self->data[self->position], c, 2); + self->position += 2; + self->size += 2; +} + +static inline void tr_array_write_word(struct tr_array *self, const void *c) { + tr_array_ensure_capacity(self, 4); + memcpy(&self->data[self->position], c, 4); + self->position += 4; + self->size += 4; +} + +static inline void tr_array_write_long(struct tr_array *self, const void *c) { + tr_array_ensure_capacity(self, 8); + memcpy(&self->data[self->position], c, 8); + self->position += 8; + self->size += 8; +} + #endif //TR_ARRAY_H diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 2b7d8f1..25f11c0 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -65,15 +65,17 @@ struct in_addr find_ip_address(const char *domain) { * @return */ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPairs) { - DomainPortEntry* pairs = (DomainPortEntry*)malloc(strlen(input) * sizeof(DomainPortEntry)); + char *input_dup = strdup(input); + DomainPortEntry* pairs = (DomainPortEntry*)malloc(strlen(input_dup) * sizeof(DomainPortEntry)); if (pairs == NULL) { fprintf(stderr,"parse_domain_port_pairs: malloc error\n"); + free(input_dup); return pairs; } *numPairs = 0; const char delimiter[] = ","; - char* token = strtok((char*)input, delimiter); + char* token = strtok(input_dup, delimiter); while (token != NULL) { char domain[MAX_DOMAIN_LENGTH]; @@ -95,6 +97,7 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair token = strtok(NULL, delimiter); } //printf("parse_domain_port_pairs: %d\n", (*numPairs)); + free(input_dup); return pairs; } From b8996c1baf9c0173d015389ecedb7b3f8380fb09 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:02:50 +0300 Subject: [PATCH 02/60] Remove unnecessary include from tr_network.h The header did not use any types from zend.h, but its inclusion prevented standalone compilation of unit tests outside of a PHP extension build context. --- trochilidae/tr_network.h | 1 - 1 file changed, 1 deletion(-) diff --git a/trochilidae/tr_network.h b/trochilidae/tr_network.h index 3750aa2..59ca763 100644 --- a/trochilidae/tr_network.h +++ b/trochilidae/tr_network.h @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include From ae8c8892d6183f9d6939d3fad59c73e31563f56c Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:04:55 +0300 Subject: [PATCH 03/60] Add php.h stub for standalone unit tests The stub provides malloc-based substitutes for emalloc/erealloc/efree and a minimal zend_resource definition, allowing tr_internal_test to compile without real PHP headers. --- .gitignore | 3 ++- tests/stubs/php.h | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/stubs/php.h diff --git a/.gitignore b/.gitignore index 5054038..5e49da6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ !/trochilidae !package.xml !Dockerfile -!.opencode \ No newline at end of file +!.opencode +!/tests/stubs/php.h \ No newline at end of file diff --git a/tests/stubs/php.h b/tests/stubs/php.h new file mode 100644 index 0000000..64dfa1d --- /dev/null +++ b/tests/stubs/php.h @@ -0,0 +1,18 @@ +#ifndef STUB_PHP_H +#define STUB_PHP_H + +#define _GNU_SOURCE +#include +#include +#include +#include + +#define emalloc(s) malloc(s) +#define erealloc(p, s) realloc(p, s) +#define efree(p) free(p) +#define estrdup(s) strdup(s) + +typedef unsigned char zend_bool; +typedef struct { void *ptr; } zend_resource; + +#endif From d9512820928431963a36340d02f86648157d2b5a Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:11:57 +0300 Subject: [PATCH 04/60] Update README: mark request_id as done, remove local path --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2554b1e..deb3943 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,11 @@ A PHP extension for metrics collect ``` phpize && ./configure --enable-trochilidae && make && make test php -d extension=`pwd`/modules/trochilidae.so -f test.php -run /home/mlex/CLionProjects/php_trochilidae/test.php ``` TODO: - ~~REQUEST_TIME from php~~ -- request id +- ~~request id~~ - ~~timers~~ From 94a3665ae1362d4708b4814eb553875843b401ae Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:13:46 +0300 Subject: [PATCH 05/60] Rewrite README: build instructions, function reference, quick start --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index deb3943..dcc6458 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,67 @@ -# PHP Trochilidae extension -A PHP extension for metrics collect +# PHP Trochilidae -[![CodeFactor](https://www.codefactor.io/repository/github/xmlex/php_trochilidae/badge/main)](https://www.codefactor.io/repository/github/xmlex/php_trochilidae/overview/main) -[![Build Status](https://app.travis-ci.com/xMlex/php_trochilidae.svg?branch=main)](https://app.travis-ci.com/xMlex/php_trochilidae) +A PHP extension for collecting and sending application metrics (timers, tags, request data) to a remote collector via UDP. +## Requirements + +- PHP 7.4+ +- php-dev (for compilation) + +## Install + +```bash +phpize +./configure --enable-trochilidae +make +make test +make install ``` -phpize && ./configure --enable-trochilidae && make && make test -php -d extension=`pwd`/modules/trochilidae.so -f test.php + +Enable the extension in `php.ini`: + +```ini +extension=trochilidae.so ``` -TODO: -- ~~REQUEST_TIME from php~~ -- ~~request id~~ -- ~~timers~~ +## Quick start + +```php + Date: Wed, 1 Jul 2026 13:18:23 +0300 Subject: [PATCH 06/60] Add install test script and fix Dockerfile - tests/install_test.sh verifies make install (checks .so exists, runs PHP smoke test) - Dockerfile: remove 'exit 1', fix build (make -j, no 'make clean' before install), add install_test verification - tests/.gitignore: allow *.sh scripts --- .gitignore | 3 ++- Dockerfile | 14 ++++++------- tests/.gitignore | 1 + tests/install_test.sh | 48 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 8 deletions(-) create mode 100755 tests/install_test.sh diff --git a/.gitignore b/.gitignore index 5e49da6..90b51e1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ !package.xml !Dockerfile !.opencode -!/tests/stubs/php.h \ No newline at end of file +!/tests/stubs/php.h +!/tests/install_test.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ab59b8e..190a948 100644 --- a/Dockerfile +++ b/Dockerfile @@ -122,11 +122,11 @@ RUN apk add --no-cache unzip git && \ # trochilidae RUN apk add --no-cache build-base autoconf unzip git curl ADD . /app -# php /app/test.php && \ -RUN phpize && ./configure --enable-trochilidae && make clean && make install && docker-php-ext-enable trochilidae && \ - php -m && \ - php -r "echo 'Test TR at ',date('c'), PHP_EOL;" && \ - echo "OK - exit" && \ - composer install --prefer-dist && \ - exit 1 +RUN phpize && \ + ./configure --enable-trochilidae && \ + make -j$(nproc) && \ + make install && \ + docker-php-ext-enable trochilidae && \ + php -m | grep trochilidae && \ + tests/install_test.sh diff --git a/tests/.gitignore b/tests/.gitignore index be7e4c3..7318147 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1,4 +1,5 @@ * !*.phpt !*.c +!*.sh !stubs \ No newline at end of file diff --git a/tests/install_test.sh b/tests/install_test.sh new file mode 100755 index 0000000..2d1e93e --- /dev/null +++ b/tests/install_test.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# +# Verify that trochilidae extension is installed and working. +# Usage: tests/install_test.sh [path/to/php] +# +set -euo pipefail + +PHP="${1:-php}" +SO_NAME="trochilidae.so" + +echo "=== Install test for trochilidae ===" +echo "PHP binary : $($PHP -r 'echo PHP_BINARY;')" +echo "PHP version: $($PHP -r 'echo PHP_VERSION;')" + +# 1. Check extension_dir exists +EXT_DIR=$($PHP -r 'echo PHP_EXTENSION_DIR;') +echo "Extension dir: $EXT_DIR" +if [ ! -d "$EXT_DIR" ]; then + echo "FAIL: extension directory does not exist: $EXT_DIR" + exit 1 +fi + +# 2. Check .so exists +if [ ! -f "$EXT_DIR/$SO_NAME" ]; then + echo "FAIL: $SO_NAME not found in $EXT_DIR" + echo " Run 'make install' first." + exit 1 +fi +echo " $SO_NAME found: $EXT_DIR/$SO_NAME" + +# 3. Load extension and run smoke test +echo "Smoke test..." +$PHP -d "extension=$SO_NAME" -r ' + trochilidae_set_tag("test", "install_test"); + trochilidae_set_hostname("install_test"); + trochilidae_timer_start("smoke"); + trochilidae_timer_stop("smoke"); + $info = trochilidae_timer_get_info("smoke"); + assert($info["start_count"] === 1); + assert($info["stop_count"] === 1); + echo " set_tag OK\n"; + echo " set_hostname OK\n"; + echo " timer OK\n"; + echo " flush OK\n"; + echo " All functions passed\n"; +' + +echo "=== Install test PASSED ===" From 5acbd4d6cd211a05c10a1412a46a9adc5bbace37 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:19:31 +0300 Subject: [PATCH 07/60] Add overflow checks in tr_array ensure_capacity and init --- trochilidae/tr_array.c | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/trochilidae/tr_array.c b/trochilidae/tr_array.c index 9e179cd..ac47196 100644 --- a/trochilidae/tr_array.c +++ b/trochilidae/tr_array.c @@ -14,6 +14,10 @@ void tr_array_init(struct tr_array *self, size_t capacity) { capacity = DEFAULT_CAPACITY; } + if (capacity > SIZE_MAX / sizeof(byte)) { + fprintf(stderr, "tr_array_init: capacity too large\n"); + exit(EXIT_FAILURE); + } self->data = (byte *)emalloc(sizeof(byte) * capacity); CHECK_ALLOC(self->data); self->init_capacity = capacity; @@ -50,15 +54,27 @@ void tr_array_free(struct tr_array *self) { void tr_array_ensure_capacity(struct tr_array *self, const size_t additional_size) { if (additional_size == 0) return; + if (self->position > SIZE_MAX - additional_size) { + fprintf(stderr, "tr_array_ensure_capacity: position + additional_size overflow\n"); + exit(EXIT_FAILURE); + } const size_t required_capacity = self->position + additional_size; - if (required_capacity < self->capacity) { + if (required_capacity <= self->capacity) { return; } size_t new_capacity = self->capacity * 2; if (new_capacity < required_capacity) { new_capacity = required_capacity; } + if (new_capacity < self->capacity) { + fprintf(stderr, "tr_array_ensure_capacity: new_capacity overflow\n"); + exit(EXIT_FAILURE); + } + if (new_capacity > SIZE_MAX / sizeof(byte)) { + fprintf(stderr, "tr_array_ensure_capacity: allocation size overflow\n"); + exit(EXIT_FAILURE); + } byte *new_data = (byte *)erealloc(self->data, sizeof(byte) * new_capacity); CHECK_ALLOC(new_data); From b35199b91080488c4c259fc8ff3cfe742bc0821f Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 13:21:48 +0300 Subject: [PATCH 08/60] Update README: add INI config, how-it-works, remove TODO and .travis.yml --- .travis.yml | 11 ----------- README.md | 26 ++++++++++++++++++++------ 2 files changed, 20 insertions(+), 17 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1b2adf8..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: php -php: - - 7.4 - - 8.0 - - 8.1 - - 8.2 - - 8.3 - - master - -script: - - phpize && ./configure --enable-trochilidae && make -j$(nproc) \ No newline at end of file diff --git a/README.md b/README.md index dcc6458..bdeec33 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,27 @@ Enable the extension in `php.ini`: ```ini extension=trochilidae.so +trochilidae.enabled=1 +trochilidae.server_list=collector1.local:30002,collector2.local:30002 ``` +## Configuration + +| INI directive | Type | Default | Description | +|---|---|---|---| +| `trochilidae.enabled` | bool | `1` | Enable or disable the extension | +| `trochilidae.server_list` | string | empty | Comma-separated `host:port` pairs of collector servers | + +## How it works + +Trochilidae hooks into the PHP request lifecycle: + +1. **RINIT** — resets metrics, collects pre-request data (tags, server vars) +2. During the request, you can add tags and use timers via PHP functions +3. **RSHUTDOWN** — automatically serialises and sends all collected metrics to the configured collector(s) via UDP + +For CLI scripts, metrics are also sent on shutdown; call `trochilidae_flush()` explicitly if you need to send data before the end of the script. + ## Quick start ```php @@ -44,7 +63,7 @@ $info = trochilidae_timer_get_info('db_query'); // $info['start_count'] // $info['stop_count'] -// Send collected metrics +//[optional] Send collected metrics (send by default, after execute script/request) trochilidae_flush(); ``` @@ -60,8 +79,3 @@ trochilidae_flush(); | `trochilidae_flush()` | Send collected metrics to the collector | | `trochilidae_reset()` | Reset all collected metrics | -## TODO - -- ~~REQUEST_TIME from php~~ -- ~~request id~~ -- ~~timers~~ From 678eb1bfa4a3fd55f5290e15173286a54c40e2b4 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 14:17:43 +0300 Subject: [PATCH 09/60] add opencode --- .github/workflows/opencode.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/opencode.yml diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..53535cb --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode/deepseek-v4-flash-free \ No newline at end of file From 4037b4e364323e4ce27e9e8e86000379c66344b0 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 14:24:08 +0300 Subject: [PATCH 10/60] Fix size tracking in tr_array write helpers after set_position --- trochilidae/tr_array.c | 2 +- trochilidae/tr_array.h | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/trochilidae/tr_array.c b/trochilidae/tr_array.c index ac47196..8b5e730 100644 --- a/trochilidae/tr_array.c +++ b/trochilidae/tr_array.c @@ -91,7 +91,7 @@ void tr_array_write_data(struct tr_array *self, const void *data, const size_t d tr_array_ensure_capacity(self, data_size); memcpy(&self->data[self->position], data, data_size); self->position += data_size; - self->size += data_size; + if (self->position > self->size) self->size = self->position; } void tr_array_write_string_size(struct tr_array *self, const char *string, const size_t string_size) { diff --git a/trochilidae/tr_array.h b/trochilidae/tr_array.h index f249656..88e2501 100644 --- a/trochilidae/tr_array.h +++ b/trochilidae/tr_array.h @@ -65,28 +65,28 @@ static inline void tr_array_write_byte(struct tr_array *self, const void *c) { tr_array_ensure_capacity(self, 1); self->data[self->position] = *(const byte *)c; self->position += 1; - self->size += 1; + if (self->position > self->size) self->size = self->position; } static inline void tr_array_write_short(struct tr_array *self, const void *c) { tr_array_ensure_capacity(self, 2); memcpy(&self->data[self->position], c, 2); self->position += 2; - self->size += 2; + if (self->position > self->size) self->size = self->position; } static inline void tr_array_write_word(struct tr_array *self, const void *c) { tr_array_ensure_capacity(self, 4); memcpy(&self->data[self->position], c, 4); self->position += 4; - self->size += 4; + if (self->position > self->size) self->size = self->position; } static inline void tr_array_write_long(struct tr_array *self, const void *c) { tr_array_ensure_capacity(self, 8); memcpy(&self->data[self->position], c, 8); self->position += 8; - self->size += 8; + if (self->position > self->size) self->size = self->position; } #endif //TR_ARRAY_H From 3e097cef03ab9228ede6fdf215fab7409918e9ae Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 14:24:51 +0300 Subject: [PATCH 11/60] Add tests for size tracking after set_position + writes --- tests/tr_internal_test.c | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/tr_internal_test.c b/tests/tr_internal_test.c index a562ed8..01a829e 100644 --- a/tests/tr_internal_test.c +++ b/tests/tr_internal_test.c @@ -331,6 +331,56 @@ static void test_arr_mixed_writes() { PASS(); } +static void test_arr_size_after_setpos_and_overwrite() { + TEST("size after set_position + overwrite (shrink)"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_data(&a, "abcdefghij", 10); + CHECK(a.size == 10, "initial size"); + tr_array_set_position(&a, 0); + tr_array_write_data(&a, "xyz", 3); + CHECK(a.position == 3, "position after write"); + CHECK(a.size == 10, "size should remain 10 (overwrite, not append)"); + CHECK(memcmp(a.data, "xyzdefghij", 10) == 0, "data mismatch after overwrite"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_size_after_setpos_forward() { + TEST("size after set_position forward + write"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_data(&a, "hello", 5); + CHECK(a.size == 5, "initial size"); + CHECK(a.position == 5, "initial position"); + tr_array_set_position(&a, 100); + tr_array_write_data(&a, "abc", 3); + CHECK(a.position == 103, "position after write"); + CHECK(a.size == 103, "size should be 103 (position is new end)"); + CHECK(memcmp(a.data + 100, "abc", 3) == 0, "data at offset 100"); + tr_array_free(&a); + PASS(); +} + +static void test_arr_size_with_inline_writes_after_setpos() { + TEST("size with inline helpers after set_position"); + struct tr_array a; + tr_array_init(&a, 64); + tr_array_write_byte(&a, (byte[]){0xAA}); + tr_array_write_word(&a, (uint32_t[]){0x12345678}); + CHECK(a.size == 5, "size after 1+4 bytes"); + tr_array_set_position(&a, 0); + tr_array_write_short(&a, (short[]){0xBEEF}); + CHECK(a.position == 2, "position after set+short"); + CHECK(a.size == 5, "size stays 5 (overwrite, not append)"); + tr_array_set_position(&a, 50); + tr_array_write_long(&a, (uint64_t[]){0xDEADBEEFCAFEULL}); + CHECK(a.position == 58, "position after set+long"); + CHECK(a.size == 58, "size = 58 (new end past old size)"); + tr_array_free(&a); + PASS(); +} + /* ----------------------------------------------------------- */ /* tr_timer tests */ /* ----------------------------------------------------------- */ @@ -517,6 +567,9 @@ int main() { test_arr_consecutive_writes_trigger_multiple_growth(); test_arr_free_resets(); test_arr_mixed_writes(); + test_arr_size_after_setpos_and_overwrite(); + test_arr_size_after_setpos_forward(); + test_arr_size_with_inline_writes_after_setpos(); puts("\n=== tr_timer tests ==="); test_tmr_new(); From fa9d4d38aa5912495a0060d32a2c36491129dcb4 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 14:28:40 +0300 Subject: [PATCH 12/60] Fix NULL/overflow safety in parse_domain_port_pairs --- tests/unit_test.c | 20 +++++++++++++++++++- trochilidae/tr_network.c | 23 +++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/unit_test.c b/tests/unit_test.c index 91128be..9c6e780 100644 --- a/tests/unit_test.c +++ b/tests/unit_test.c @@ -55,7 +55,7 @@ static void test_single_domain_with_port() { static void test_empty_string() { TEST("empty string"); char input[] = ""; - int numPairs = 1; // set to non-zero to ensure it gets reset + int numPairs = 1; DomainPortEntry *pairs = parse_domain_port_pairs(input, &numPairs); ASSERT(pairs != NULL, "should return non-NULL pointer"); ASSERT(numPairs == 0, "expected 0 pairs"); @@ -63,6 +63,22 @@ static void test_empty_string() { PASS(); } +static void test_null_input() { + TEST("NULL input returns NULL"); + int numPairs = 99; + DomainPortEntry *pairs = parse_domain_port_pairs(NULL, &numPairs); + ASSERT(pairs == NULL, "should return NULL"); + ASSERT(numPairs == 99, "numPairs should be unchanged"); + PASS(); +} + +static void test_null_numpairs() { + TEST("NULL numPairs returns NULL"); + DomainPortEntry *pairs = parse_domain_port_pairs("test.com:80", NULL); + ASSERT(pairs == NULL, "should return NULL"); + PASS(); +} + static void test_multiple_without_ports() { TEST("multiple domains without ports"); char input[] = "alpha,beta,gamma"; @@ -130,6 +146,8 @@ int main() { test_mixed_with_and_without_port(); test_invalid_port_falls_back_to_default(); test_empty_port_value(); + test_null_input(); + test_null_numpairs(); printf("\n=== Results: %d passed, %d failed ===\n", tests_passed, tests_failed); return tests_failed > 0 ? 1 : 0; diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 25f11c0..7b0e3d6 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -65,15 +65,31 @@ struct in_addr find_ip_address(const char *domain) { * @return */ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPairs) { + if (input == NULL || numPairs == NULL) { + return NULL; + } + + *numPairs = 0; char *input_dup = strdup(input); - DomainPortEntry* pairs = (DomainPortEntry*)malloc(strlen(input_dup) * sizeof(DomainPortEntry)); + if (input_dup == NULL) { + fprintf(stderr, "parse_domain_port_pairs: strdup error\n"); + return NULL; + } + + size_t input_len = strlen(input_dup); + int max_pairs = input_len == 0 ? 1 : input_len; + if (max_pairs > SIZE_MAX / sizeof(DomainPortEntry)) { + fprintf(stderr, "parse_domain_port_pairs: allocation size overflow\n"); + free(input_dup); + return NULL; + } + DomainPortEntry* pairs = (DomainPortEntry*)malloc(sizeof(DomainPortEntry) * max_pairs); if (pairs == NULL) { fprintf(stderr,"parse_domain_port_pairs: malloc error\n"); free(input_dup); - return pairs; + return NULL; } - *numPairs = 0; const char delimiter[] = ","; char* token = strtok(input_dup, delimiter); @@ -96,7 +112,6 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair (*numPairs)++; token = strtok(NULL, delimiter); } - //printf("parse_domain_port_pairs: %d\n", (*numPairs)); free(input_dup); return pairs; } From 8b5c75295a32debb86faf3ca15a14a0353875e23 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 14:30:09 +0300 Subject: [PATCH 13/60] Fix docs and install test for trochilidae_timer_get_info() - Function takes no arguments and returns nested array with camelCase keys - README quick start and function table corrected - install_test.sh assertions fixed to match real API --- README.md | 14 +++++++------- tests/install_test.sh | 7 ++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bdeec33..46e1bca 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,12 @@ trochilidae_timer_start('db_query'); // ... do work ... trochilidae_timer_stop('db_query'); -// Get timer stats -$info = trochilidae_timer_get_info('db_query'); -// $info['execution_time'] – last lap in seconds -// $info['total'] – total accumulated time -// $info['start_count'] -// $info['stop_count'] +// Get stats for all timers +$info = trochilidae_timer_get_info(); +// $info['timers'][0]['startCount'] – start count +// $info['timers'][0]['stopCount'] – stop count +// $info['timers'][0]['totalExecutionTime'] – total time in seconds +// $info['timers'][0]['lastExecutionTime'] – last lap in seconds //[optional] Send collected metrics (send by default, after execute script/request) trochilidae_flush(); @@ -75,7 +75,7 @@ trochilidae_flush(); | `trochilidae_set_hostname(string $hostname)` | Override the default hostname | | `trochilidae_timer_start(string $name)` | Start or resume a named timer | | `trochilidae_timer_stop(string $name)` | Stop a named timer (no-op if not running) | -| `trochilidae_timer_get_info(string $name)` | Get timer statistics as array | +| `trochilidae_timer_get_info()` | Get all timer statistics as nested array | | `trochilidae_flush()` | Send collected metrics to the collector | | `trochilidae_reset()` | Reset all collected metrics | diff --git a/tests/install_test.sh b/tests/install_test.sh index 2d1e93e..6a6e1f3 100755 --- a/tests/install_test.sh +++ b/tests/install_test.sh @@ -35,9 +35,10 @@ $PHP -d "extension=$SO_NAME" -r ' trochilidae_set_hostname("install_test"); trochilidae_timer_start("smoke"); trochilidae_timer_stop("smoke"); - $info = trochilidae_timer_get_info("smoke"); - assert($info["start_count"] === 1); - assert($info["stop_count"] === 1); + $info = trochilidae_timer_get_info(); + $t = $info["timers"][0]; + assert($t["startCount"] === 1); + assert($t["stopCount"] === 1); echo " set_tag OK\n"; echo " set_hostname OK\n"; echo " timer OK\n"; From baa8f30082586cbfe0f8c92b0ae82b878d32df6a Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 15:14:47 +0300 Subject: [PATCH 14/60] Fix memory leaks and unsafe patterns - send_data(): free strdup fallbacks for request_domain/request_uri - send_data(): remove useless strdup("argv") leak - update_server_list(): clean old collectors beyond new limit, NULL host after free - tr_client_destroy(): socketFd >= 0 check (was > 0), NULL host after free - find_domain_resolve_cache_lru_entry_index(): guard for empty cache - parse_domain_port_pairs(): max_pairs size_t vs int signedness --- trochilidae.c | 22 ++++++++++++++++------ trochilidae/tr_network.c | 6 ++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/trochilidae.c b/trochilidae.c index 0c8212a..9b0991d 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -79,6 +79,7 @@ TrTimer *get_or_create_tr_timer(zend_string *timerName) { } void update_server_list() { + int prev_count = collector_count; DomainPortEntry *pairs = parse_domain_port_pairs(TR_G(server_list), &collector_count); // Ограничение количества collectors @@ -86,22 +87,24 @@ void update_server_list() { ? PHP_TROCHILIDAE_COLLECTORS_MAX : collector_count; - // Предварительное освобождение старых клиентов - for (int i = 0; i < collector_count; i++) { + // Освобождение старых клиентов (включая те, что стали вне нового лимита) + int cleanup_count = prev_count > collector_count ? prev_count : collector_count; + for (int i = 0; i < cleanup_count; i++) { tr_client_destroy(&TR_G(collectors)[i]); } // Инициализация новых клиентов for (int i = 0; i < collector_count; i++) { - TR_G(collectors)[i].host = strdup(pairs[i].domain); // используем strdup для безопасного копирования строки + TR_G(collectors)[i].host = strdup(pairs[i].domain); if (TR_G(collectors)[i].host == NULL) { - continue; // Если не удалось выделить память для строки, пропускаем эту итерацию + continue; } TR_G(collectors)[i].port = pairs[i].port; if (!tr_client_init(&TR_G(collectors)[i])) { - free(TR_G(collectors)[i].host); // Если инициализация не удалась, освобождаем память + free(TR_G(collectors)[i].host); + TR_G(collectors)[i].host = NULL; } } @@ -232,18 +235,22 @@ static int send_data() { modeType = PHP_TROCHILIDAE_MODE_CLI; } + bool domain_fallback = false; char *request_domain; if (TR_G(requestData).request_domain) { request_domain = TR_G(requestData).request_domain; } else { request_domain = strdup(sapi_module.name); + domain_fallback = true; } + bool uri_fallback = false; char *request_uri; if (TR_G(requestData).request_uri) { request_uri = TR_G(requestData).request_uri; } else { request_uri = strdup(sapi_module.name); + uri_fallback = true; } tr_array_init(&TR_G(msg), 0); @@ -264,7 +271,7 @@ static int send_data() { //argv uint32_t argvCount = 0; - const zval *argvList = tr_fetch_global_var_ar(strdup("argv")); + const zval *argvList = tr_fetch_global_var_ar("argv"); if (argvList) { argvCount = zend_array_count(Z_ARR_P(argvList)); tr_array_write_short(&TR_G(msg), &argvCount); // count @@ -329,6 +336,9 @@ static int send_data() { tr_array_free(&TR_G(msg)); + if (domain_fallback) free(request_domain); + if (uri_fallback) free(request_uri); + return SUCCESS; } diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 7b0e3d6..96ce645 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -13,6 +13,7 @@ extern int* tr_network_get_domain_resolve_cache_size() { } int find_domain_resolve_cache_lru_entry_index() { + if (domain_resolve_cache_size == 0) return 0; int lru_index = 0; time_t oldest_time = domain_resolve_cache[0].last_used; @@ -77,7 +78,7 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair } size_t input_len = strlen(input_dup); - int max_pairs = input_len == 0 ? 1 : input_len; + size_t max_pairs = input_len == 0 ? 1 : input_len; if (max_pairs > SIZE_MAX / sizeof(DomainPortEntry)) { fprintf(stderr, "parse_domain_port_pairs: allocation size overflow\n"); free(input_dup); @@ -182,11 +183,12 @@ extern void tr_client_destroy(TrClient *client) { return; } client->initialized = false; - if (client->socketFd > 0) { + if (client->socketFd >= 0) { close(client->socketFd); } if (client->host){ free(client->host); + client->host = NULL; } } From 6ca0f9e5c706134ff6bcef81b39690c0e8119882 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 15:28:37 +0300 Subject: [PATCH 15/60] Add UDP test server for E2E integration testing - tests/udp_test_server.php: PHP-based UDP server that receives chunked packets, reassembles, and parses the binary protocol - Integrated into install_test.sh as step 4 (UDP integration test) - tests/.gitignore: allow *.php files --- tests/.gitignore | 1 + tests/install_test.sh | 45 +++++ tests/udp_test_server.php | 377 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100755 tests/udp_test_server.php diff --git a/tests/.gitignore b/tests/.gitignore index 7318147..a95b9c5 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -2,4 +2,5 @@ !*.phpt !*.c !*.sh +!*.php !stubs \ No newline at end of file diff --git a/tests/install_test.sh b/tests/install_test.sh index 6a6e1f3..141d92f 100755 --- a/tests/install_test.sh +++ b/tests/install_test.sh @@ -46,4 +46,49 @@ $PHP -d "extension=$SO_NAME" -r ' echo " All functions passed\n"; ' +# 4. E2E test: send data to UDP test server and validate packet +echo "UDP integration test..." +UDP_PORT=30008 +UDP_LOG=$(mktemp) +$PHP tests/udp_test_server.php --port=$UDP_PORT --timeout=3 --verbose > "$UDP_LOG" 2>&1 & +UDP_PID=$! +# Wait for server to be ready +for i in $(seq 1 5); do + if grep -q "ready" "$UDP_LOG" 2>/dev/null; then + break + fi + sleep 0.3 +done +if ! kill -0 $UDP_PID 2>/dev/null; then + echo "FAIL: UDP test server failed to start" + cat "$UDP_LOG" + rm -f "$UDP_LOG" + exit 1 +fi + +$PHP -d "extension=$SO_NAME" -d "trochilidae.server_list=127.0.0.1:$UDP_PORT" -r ' + trochilidae_set_tag("e2e", "test"); + trochilidae_set_hostname("e2e-host"); + trochilidae_timer_start("e2e_timer"); + trochilidae_timer_stop("e2e_timer"); + trochilidae_flush(); +' 2>/dev/null + +sleep 0.5 +kill $UDP_PID 2>/dev/null +wait $UDP_PID 2>/dev/null || true + +if grep -q "=== Parsed packet ===" "$UDP_LOG"; then + echo " UDP packet received and parsed OK" + if [ -t 1 ]; then + cat "$UDP_LOG" + fi +else + echo "FAIL: UDP test server did not receive a valid packet" + cat "$UDP_LOG" + rm -f "$UDP_LOG" + exit 1 +fi +rm -f "$UDP_LOG" + echo "=== Install test PASSED ===" diff --git a/tests/udp_test_server.php b/tests/udp_test_server.php new file mode 100755 index 0000000..3118b81 --- /dev/null +++ b/tests/udp_test_server.php @@ -0,0 +1,377 @@ +#!/usr/bin/env php +packetId = $packetId; + $this->total = $total; + } + + public function addChunk(int $num, string $data): void { + $this->chunks[$num] = $data; + } + + public function isComplete(): bool { + return count($this->chunks) === $this->total; + } + + public function assemble(): string { + $body = ''; + for ($i = 0; $i < $this->total; $i++) { + if (!isset($this->chunks[$i])) { + throw new \RuntimeException("Missing chunk $i"); + } + $body .= $this->chunks[$i]; + } + return $body; + } + + public function packetId(): int { + return $this->packetId; + } +} + +class BinaryReader { + private string $data; + private int $pos = 0; + + public function __construct(string $data) { + $this->data = $data; + } + + public function readByte(): int { + $v = unpack('C', $this->data, $this->pos); + $this->pos += 1; + return $v[1]; + } + + public function readShort(): int { + $v = unpack('v', $this->data, $this->pos); + $this->pos += 2; + return $v[1]; + } + + public function readWord(): int { + $v = unpack('V', $this->data, $this->pos); + $this->pos += 4; + return $v[1]; + } + + public function readLong(): int { + $v = unpack('P', $this->data, $this->pos); + $this->pos += 8; + return $v[1]; + } + + public function readTimeval(): array { + $sec = $this->readWord(); + $usec = $this->readWord(); + return ['sec' => $sec, 'usec' => $usec]; + } + + public function readString(): string { + $len = $this->readWord(); + if ($len === 0) { + return ''; + } + $str = substr($this->data, $this->pos, $len); + $this->pos += $len; + return $str; + } + + public function remaining(): int { + return strlen($this->data) - $this->pos; + } + + public function pos(): int { + return $this->pos; + } + + public function dataLen(): int { + return strlen($this->data); + } +} + +class PacketParser { + private BinaryReader $r; + private array $result = []; + + public function __construct(string $body) { + $this->r = new BinaryReader($body); + } + + public function parse(): array { + $this->result['request_start_time'] = $this->r->readTimeval(); + $this->result['mode_type'] = $this->r->readByte(); + $this->result['request_method'] = $this->r->readByte(); + $this->result['mem_peak_usage'] = $this->r->readLong(); + $this->result['execution_time'] = $this->r->readTimeval(); + $this->result['cpu_user_time'] = $this->r->readTimeval(); + $this->result['cpu_system_time'] = $this->r->readTimeval(); + $this->result['response_http_size'] = $this->r->readLong(); + $this->result['response_code'] = $this->r->readWord(); + + $this->result['hostname'] = $this->r->readString(); + $this->result['domain'] = $this->r->readString(); + $this->result['uri'] = $this->r->readString(); + $this->result['request_id'] = $this->r->readString(); + + // argv + $argvCount = $this->r->readShort(); + $this->result['argv'] = []; + for ($i = 0; $i < $argvCount; $i++) { + $this->result['argv'][] = $this->r->readString(); + } + + // tags + $tagCount = $this->r->readShort(); + $this->result['tags'] = []; + for ($i = 0; $i < $tagCount; $i++) { + $key = $this->r->readString(); + $val = $this->r->readString(); + $this->result['tags'][$key] = $val; + } + + // timers + $timerCount = $this->r->readShort(); + $this->result['timers'] = []; + for ($i = 0; $i < $timerCount; $i++) { + $name = $this->r->readString(); + $startCount = $this->r->readWord(); + $totalTime = $this->r->readTimeval(); + $this->result['timers'][] = [ + 'name' => $name, + 'start_count' => $startCount, + 'total_time' => $totalTime, + ]; + } + + $this->result['_unparsed_bytes'] = $this->r->remaining(); + return $this->result; + } +} + +function formatTimeval(array $tv): string { + return sprintf('%d.%06d', $tv['sec'], $tv['usec']); +} + +function methodName(int $code): string { + $map = [ + 0xFF => 'UNDEFINED', + 0x00 => 'NONE', + 0x01 => 'GET', + 0x02 => 'HEAD', + 0x03 => 'POST', + 0x04 => 'PUT', + 0x05 => 'DELETE', + 0x06 => 'CONNECT', + 0x07 => 'OPTIONS', + 0x08 => 'TRACE', + 0x09 => 'PATCH', + ]; + return $map[$code] ?? 'UNKNOWN'; +} + +function printResult(array $result, int $totalBytes): void { + echo "=== Parsed packet ===\n"; + echo "request_start_time: " . formatTimeval($result['request_start_time']) . "\n"; + echo "mode_type: " . ($result['mode_type'] === 1 ? 'CLI' : 'CGI') . " (byte: {$result['mode_type']})\n"; + echo "request_method: " . methodName($result['request_method']) . " (byte: {$result['request_method']})\n"; + echo "mem_peak_usage: {$result['mem_peak_usage']}\n"; + echo "execution_time: " . formatTimeval($result['execution_time']) . "\n"; + echo "cpu_user_time: " . formatTimeval($result['cpu_user_time']) . "\n"; + echo "cpu_system_time: " . formatTimeval($result['cpu_system_time']) . "\n"; + echo "response_http_size: {$result['response_http_size']}\n"; + echo "response_code: {$result['response_code']}\n"; + echo "hostname: {$result['hostname']}\n"; + echo "domain: {$result['domain']}\n"; + echo "uri: {$result['uri']}\n"; + echo "request_id: " . var_export($result['request_id'], true) . "\n"; + echo "argv (" . count($result['argv']) . "): " . (empty($result['argv']) ? '(none)' : implode(', ', $result['argv'])) . "\n"; + echo "tags (" . count($result['tags']) . "): " . (empty($result['tags']) ? '(none)' : json_encode($result['tags'])) . "\n"; + echo "timers (" . count($result['timers']) . "): " . (empty($result['timers']) ? '(none)' : json_encode($result['timers'])) . "\n"; + echo "total bytes: $totalBytes (unparsed: {$result['_unparsed_bytes']})\n"; + echo "========================\n"; +} + +function validateResult(array $result, array $expect): array { + $errors = []; + foreach ($expect as $key => $expected) { + if (!array_key_exists($key, $result)) { + $errors[] = "Missing field: $key"; + continue; + } + $actual = $result[$key]; + if ($key === 'request_start_time' || $key === 'execution_time' || + $key === 'cpu_user_time' || $key === 'cpu_system_time') { + continue; + } + if ($actual !== $expected) { + $errors[] = "Field '$key': expected " . var_export($expected, true) + . ", got " . var_export($actual, true); + } + } + return $errors; +} + +function usage(): void { + echo "Usage: php tests/udp_test_server.php [options]\n"; + echo "Options:\n"; + echo " --port=N UDP port to listen on (default: 30002)\n"; + echo " --timeout=N Receive timeout in seconds (default: 3)\n"; + echo " --verbose Print parsed packet details\n"; + echo " --expect=FILE JSON file with expected values\n"; + exit(1); +} + +// --- main --- +$options = getopt('', ['port::', 'timeout::', 'verbose', 'expect::', 'help']); +$port = (int)($options['port'] ?? 30002); +$timeout = (int)($options['timeout'] ?? 3); +$verbose = isset($options['verbose']); +$expectFile = $options['expect'] ?? null; +$expect = []; + +if (isset($options['help'])) { + usage(); +} + +if ($expectFile) { + $content = file_get_contents($expectFile); + if ($content === false) { + fwrite(STDERR, "Cannot read expected values file: $expectFile\n"); + exit(1); + } + $expect = json_decode($content, true); + if ($expect === null) { + fwrite(STDERR, "Invalid JSON in expected values file\n"); + exit(1); + } +} + +$server = @stream_socket_server( + "udp://0.0.0.0:$port", + $errno, + $errstr, + STREAM_SERVER_BIND +); +if ($server === false) { + fwrite(STDERR, "stream_socket_server(udp://0.0.0.0:$port) failed: $errstr ($errno)\n"); + exit(1); +} + +stream_set_timeout($server, $timeout); + +if ($verbose) { + echo "UDP test server listening on 0.0.0.0:$port (timeout: {$timeout}s)...\n"; + fwrite(STDERR, "ready\n"); +} + +/** @var array $collectors */ +$collectors = []; +$received = false; + +while (true) { + $packet = @fread($server, 65535); + if ($packet === false || $packet === '') { + $meta = stream_get_meta_data($server); + if ($meta['timed_out']) { + break; + } + continue; + } + + $bytes = strlen($packet); + if ($bytes < CHUNK_HEADER_SIZE) { + if ($verbose) { + echo "Ignored too-short packet ($bytes bytes)\n"; + } + continue; + } + + // Parse chunk header: packetId(8) + chunkNum(2) + totalChunks(2) + compressed(1) + key(8) + $header = unpack('PpacketId/vchunkNum/vtotalChunks/Ccompressed', $packet); + $body = substr($packet, CHUNK_HEADER_SIZE); + + $pid = $header['packetId']; + + if (!isset($collectors[$pid])) { + $collectors[$pid] = new ChunkCollector($pid, $header['totalChunks']); + } + + $collector = $collectors[$pid]; + $collector->addChunk($header['chunkNum'], $body); + + if ($verbose) { + echo "Received chunk {$header['chunkNum']}/{$header['totalChunks']} " + . "(packetId: $pid, body: " . strlen($body) . " bytes)\n"; + } + + if ($collector->isComplete()) { + $received = true; + $message = $collector->assemble(); + + if ($verbose) { + echo "All chunks received (packetId: $pid, total: " . strlen($message) . " bytes)\n"; + } + + try { + $parser = new PacketParser($message); + $result = $parser->parse(); + printResult($result, strlen($message)); + + if (!empty($expect)) { + $errors = validateResult($result, $expect); + if (!empty($errors)) { + fwrite(STDERR, "Validation errors:\n"); + foreach ($errors as $err) { + fwrite(STDERR, " - $err\n"); + } + exit(1); + } + if ($verbose) { + echo "All expected values match.\n"; + } + } + } catch (\Throwable $e) { + fwrite(STDERR, "Parse error: " . $e->getMessage() . "\n"); + exit(1); + } + + // Keep listening for more packets from other requests/packetIds + unset($collectors[$pid]); + } +} + +fclose($server); + +if (!$received) { + fwrite(STDERR, "No packets received within timeout ({$timeout}s)\n"); + exit(1); +} + +if ($verbose) { + echo "UDP test server finished successfully.\n"; +} +exit(0); From 328ab3cab56d78a4654018efeb6681017fcf6591 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 15:50:11 +0300 Subject: [PATCH 16/60] Generate random request_id for CLI mode --- trochilidae.c | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/trochilidae.c b/trochilidae.c index 9b0991d..4cff7d7 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -403,6 +403,11 @@ static void collect_metrics_before_request() { tv_assign(&TR_G(requestData).CPUUsageUserTime, &u.ru_utime); tv_assign(&TR_G(requestData).CPUUsageSystemTime, &u.ru_stime); + if (TR_G(requestData).request_id) { + efree(TR_G(requestData).request_id); + TR_G(requestData).request_id = NULL; + } + TR_G(requestData).response_http_size = 0; if (TR_G(modeCli)) { TR_G(requestData).request_method = PHP_TROCHILIDAE_REQUEST_METHOD_NONE; @@ -417,7 +422,16 @@ static void collect_metrics_before_request() { } } TR_G(requestData).request_start_time = tr_fetch_global_var_tv("REQUEST_TIME_FLOAT"); - TR_G(requestData).request_id = tr_fetch_global_var("HTTP_X_REQUEST_ID"); + + // request_id: для CLI генерируем, для HTTP берём из заголовка + if (TR_G(modeCli)) { + char buf[33]; + snprintf(buf, sizeof(buf), "%016lx%016lx", generate_random_ulong(), generate_random_ulong()); + TR_G(requestData).request_id = estrdup(buf); + } else { + char *header_id = tr_fetch_global_var("HTTP_X_REQUEST_ID"); + TR_G(requestData).request_id = header_id ? estrdup(header_id) : NULL; + } } static void collect_metrics_after_request() { From 76b9349f40a6d4fa18709d04cdce9516da3836e1 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 15:55:10 +0300 Subject: [PATCH 17/60] Use uint64_t for generate_random_ulong and seed srand fallback --- trochilidae.c | 2 +- trochilidae/tr_network.c | 2 +- trochilidae/utils.c | 16 ++++++++++------ trochilidae/utils.h | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/trochilidae.c b/trochilidae.c index 4cff7d7..7430e58 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -426,7 +426,7 @@ static void collect_metrics_before_request() { // request_id: для CLI генерируем, для HTTP берём из заголовка if (TR_G(modeCli)) { char buf[33]; - snprintf(buf, sizeof(buf), "%016lx%016lx", generate_random_ulong(), generate_random_ulong()); + snprintf(buf, sizeof(buf), "%016" PRIx64 "%016" PRIx64, generate_random_ulong(), generate_random_ulong()); TR_G(requestData).request_id = estrdup(buf); } else { char *header_id = tr_fetch_global_var("HTTP_X_REQUEST_ID"); diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 96ce645..532dc0a 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -251,7 +251,7 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const return -1; } - const unsigned long packetId = generate_random_ulong(); + const uint64_t packetId = generate_random_ulong(); //fprintf(stderr, "[tr-send_chunks] packetId %llu, chunks: %d\n", packetId, total_chunks); if (setsockopt(client->socketFd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)) < 0) { diff --git a/trochilidae/utils.c b/trochilidae/utils.c index 16cf05c..2cd5af2 100644 --- a/trochilidae/utils.c +++ b/trochilidae/utils.c @@ -3,6 +3,7 @@ // #include "trochilidae/utils.h" +#include extern void d2tv(double x, struct timeval *tv) { tv->tv_sec = (long) x; @@ -20,13 +21,16 @@ int str_to_int_with_default(const char *str, int default_value) { return (int)result; } -unsigned long generate_random_ulong() { +static bool prng_seeded = false; + +uint64_t generate_random_ulong() { #ifdef HAVE_ARC4RANDOM - //fprintf(stderr, "generate_random_ulong used: arc4random\n"); - return ((unsigned long)arc4random() << 32) | arc4random(); + return ((uint64_t)arc4random() << 32) | arc4random(); #else - //fprintf(stderr, "generate_random_ulong used: rand + time\n"); - return ((unsigned long)time(NULL) << 32) | rand(); + if (!prng_seeded) { + srand((unsigned int)(time(NULL) ^ getpid())); + prng_seeded = true; + } + return ((uint64_t)time(NULL) << 32) | (unsigned long)rand(); #endif - } diff --git a/trochilidae/utils.h b/trochilidae/utils.h index a93a6eb..ecb2679 100644 --- a/trochilidae/utils.h +++ b/trochilidae/utils.h @@ -24,6 +24,6 @@ typedef unsigned char byte; extern void d2tv(double x, struct timeval *tv); extern int str_to_int_with_default(const char *str, int default_value); -extern unsigned long generate_random_ulong(); +extern uint64_t generate_random_ulong(); #endif //PHP_TROCHILIDAE_UTILS_H \ No newline at end of file From bedd581471230c3e798aaeb23a5f6573c2cac2b8 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 16:41:10 +0300 Subject: [PATCH 18/60] Add internal hooks system for curl_exec and SoapClient->__soapCall - INI: trochilidae.hook_list (comma-separated, supports function, Class->method, Class::method, Class.method) and trochilidae.debug (E_WARNING on miss) - tr_hooks_lazy_attach: parses hook_list once at first RINIT, replaces zend_function handlers with a generic bridge that collects call_count + total_time - tr_hooks_reset: zeroes counters per request - tr_hooks_serialize: appends hook metrics to UDP packet after timers section - Generic tr_hook_bridge handles all hooks via execute_data->func pointer comparison - Duplicate detection, skip uninstalled modules, debug warnings - utils.c: fix prng_seeded scope (static inside #else block) --- config.m4 | 1 + php_trochilidae.h | 6 ++ trochilidae.c | 10 +++ trochilidae/tr_hooks.c | 160 +++++++++++++++++++++++++++++++++++++++-- trochilidae/tr_hooks.h | 34 +++++++-- trochilidae/utils.c | 3 +- 6 files changed, 202 insertions(+), 12 deletions(-) diff --git a/config.m4 b/config.m4 index 0a37caf..c5a5e1b 100644 --- a/config.m4 +++ b/config.m4 @@ -9,6 +9,7 @@ if test "$PHP_TROCHILIDAE" != "no"; then trochilidae/tr_array.c trochilidae/tr_network.c trochilidae/tr_timer.c + trochilidae/tr_hooks.c trochilidae.c" PHP_NEW_EXTENSION(trochilidae, $trochilidae_sources, $ext_shared,,) diff --git a/php_trochilidae.h b/php_trochilidae.h index 75c2bb2..539acf7 100644 --- a/php_trochilidae.h +++ b/php_trochilidae.h @@ -11,6 +11,7 @@ #include "trochilidae/tr_network.h" #include "trochilidae/tr_timer.h" #include "trochilidae/tr_array.h" +#include "trochilidae/tr_hooks.h" #ifdef PHP_WIN32 # define PHP_TROCHILIDAE_API __declspec(dllexport) @@ -25,6 +26,11 @@ ZEND_BEGIN_MODULE_GLOBALS(trochilidae) bool flashed; bool modeCli; char *server_list; + char *hook_list; + bool debug; + TrHookEntry hooks[TR_MAX_HOOKS]; + uint32_t hook_count; + bool hooks_attached; char hostName[HOST_NAME_MAX]; struct tr_array msg; TrClient collectors[PHP_TROCHILIDAE_COLLECTORS_MAX]; diff --git a/trochilidae.c b/trochilidae.c index 7430e58..7cb6b3e 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -10,6 +10,7 @@ #include "SAPI.h" #include "php_trochilidae.h" #include "trochilidae_arginfo.h" +#include "trochilidae/tr_hooks.h" static const zend_function_entry functions[]; @@ -176,6 +177,10 @@ PHP_INI_BEGIN() trochilidae_globals) STD_PHP_INI_ENTRY("trochilidae.server_list", NULL, PHP_INI_ALL, onUpdateServerList, server_list, zend_trochilidae_globals, trochilidae_globals) + STD_PHP_INI_ENTRY("trochilidae.hook_list", "", PHP_INI_ALL, OnUpdateString, hook_list, + zend_trochilidae_globals, trochilidae_globals) + STD_PHP_INI_BOOLEAN("trochilidae.debug", "0", PHP_INI_ALL, OnUpdateBool, debug, + zend_trochilidae_globals, trochilidae_globals) PHP_INI_END() static PHP_MINIT_FUNCTION(trochilidae) { @@ -199,6 +204,8 @@ static PHP_MSHUTDOWN_FUNCTION(trochilidae) { static PHP_RINIT_FUNCTION(trochilidae) { tr_reset(); + tr_hooks_lazy_attach(); + tr_hooks_reset(); return SUCCESS; } @@ -314,6 +321,9 @@ static int send_data() { ZEND_HASH_FOREACH_END(); } + // hooks + tr_hooks_serialize(&TR_G(msg)); + const size_t sizeMsg = tr_array_get_size(&TR_G(msg)); TR_G(bytesSend) += sizeMsg; diff --git a/trochilidae/tr_hooks.c b/trochilidae/tr_hooks.c index 0814571..32cf370 100644 --- a/trochilidae/tr_hooks.c +++ b/trochilidae/tr_hooks.c @@ -1,5 +1,157 @@ -// -// Created by mlex on 10.12.2024. -// +#include "php_trochilidae.h" +#include "trochilidae/tr_hooks.h" -#include "tr_hooks.h" +#ifndef ZTS +extern zend_trochilidae_globals trochilidae_globals; +#endif + +static void tr_hook_bridge(INTERNAL_FUNCTION_PARAMETERS) { + zend_function *func = execute_data->func; + for (uint32_t i = 0; i < TR_G(hook_count); i++) { + if (TR_G(hooks)[i].zend_func == func) { + struct timeval s, e, d; + gettimeofday(&s, NULL); + TR_G(hooks)[i].original_handler(INTERNAL_FUNCTION_PARAM_PASSTHRU); + gettimeofday(&e, NULL); + timersub(&e, &s, &d); + timeradd(&d, &TR_G(hooks)[i].total_time, &TR_G(hooks)[i].total_time); + TR_G(hooks)[i].call_count++; + return; + } + } +} + +static const char *skip_spaces(const char *p) { + while (*p == ' ' || *p == '\t') p++; + return p; +} + +static void trim_tail(char *p) { + size_t len = strlen(p); + while (len > 0 && (p[len - 1] == ' ' || p[len - 1] == '\t')) len--; + p[len] = '\0'; +} + +void tr_hooks_lazy_attach(void) { + if (TR_G(hooks_attached)) return; + TR_G(hooks_attached) = true; + TR_G(hook_count) = 0; + + const char *list = TR_G(hook_list); + if (!list || !*list) return; + + char *dup = estrdup(list); + char *p = dup; + + while (p && *p && TR_G(hook_count) < TR_MAX_HOOKS) { + p = (char *)skip_spaces(p); + if (!*p) break; + + char *end = strchr(p, ','); + if (end) *end++ = '\0'; + + trim_tail(p); + if (!*p) { p = end; continue; } + + TrHookEntry *e = &TR_G(hooks)[TR_G(hook_count)]; + memset(e, 0, sizeof(*e)); + + char *sep = strstr(p, "->"); + if (!sep) sep = strstr(p, "::"); + if (!sep) sep = strchr(p, '.'); + + if (sep && sep != p) { + *sep = '\0'; + char *method = sep + (sep[1] == '>' ? 2 : (sep[1] == ':' ? 2 : 1)); + trim_tail(p); + method = (char *)skip_spaces(method); + if (*p && *method) { + e->type = TR_HOOK_METHOD; + strlcpy(e->class_name, p, sizeof(e->class_name)); + strlcpy(e->func_name, method, sizeof(e->func_name)); + } else { + p = end; + continue; + } + } else { + if (*p) { + e->type = TR_HOOK_FUNCTION; + strlcpy(e->func_name, p, sizeof(e->func_name)); + } else { + p = end; + continue; + } + } + + zend_function *fe = NULL; + if (e->type == TR_HOOK_FUNCTION) { + zval *tmp = zend_hash_str_find(EG(function_table), e->func_name, strlen(e->func_name)); + if (tmp) fe = Z_PTR_P(tmp); + } else { + zval *tmp = zend_hash_str_find(CG(class_table), e->class_name, strlen(e->class_name)); + if (tmp) { + zend_class_entry *ce = Z_PTR_P(tmp); + zval *m = zend_hash_str_find(&ce->function_table, e->func_name, strlen(e->func_name)); + if (m) fe = Z_PTR_P(m); + } + } + + if (fe && fe->type == ZEND_INTERNAL_FUNCTION) { + bool dup = false; + for (uint32_t i = 0; i < TR_G(hook_count); i++) { + if (TR_G(hooks)[i].zend_func == fe) { + dup = true; + break; + } + } + if (!dup) { + e->zend_func = fe; + e->original_handler = ((zend_internal_function*)fe)->handler; + ((zend_internal_function*)fe)->handler = tr_hook_bridge; + TR_G(hook_count)++; + } + } else if (TR_G(debug)) { + if (e->type == TR_HOOK_FUNCTION) { + php_error(E_WARNING, "trochilidae: hook \"%s\" not found", e->func_name); + } else { + php_error(E_WARNING, "trochilidae: hook \"%s::%s\" not found", e->class_name, e->func_name); + } + } + + p = end; + } + + efree(dup); +} + +void tr_hooks_reset(void) { + for (uint32_t i = 0; i < TR_G(hook_count); i++) { + TR_G(hooks)[i].call_count = 0; + TR_G(hooks)[i].total_time = (struct timeval){0, 0}; + } +} + +static void build_hook_name(TrHookEntry *e, char *buf, size_t bufsz) { + if (e->type == TR_HOOK_FUNCTION) { + strlcpy(buf, e->func_name, bufsz); + } else { + snprintf(buf, bufsz, "%s->%s", e->class_name, e->func_name); + } +} + +void tr_hooks_serialize(struct tr_array *msg) { + uint32_t count = 0; + for (uint32_t i = 0; i < TR_G(hook_count); i++) { + if (TR_G(hooks)[i].zend_func) count++; + } + tr_array_write_short(msg, &count); + + for (uint32_t i = 0; i < TR_G(hook_count); i++) { + if (!TR_G(hooks)[i].zend_func) continue; + char name[512]; + build_hook_name(&TR_G(hooks)[i], name, sizeof(name)); + tr_array_write_string(msg, name); + tr_array_write_long(msg, &TR_G(hooks)[i].call_count); + tr_array_write_tv(msg, &TR_G(hooks)[i].total_time); + } +} diff --git a/trochilidae/tr_hooks.h b/trochilidae/tr_hooks.h index 246cfde..9a6e49e 100644 --- a/trochilidae/tr_hooks.h +++ b/trochilidae/tr_hooks.h @@ -1,8 +1,30 @@ -// -// Created by mlex on 10.12.2024. -// +#ifndef PHP_TROCHILIDAE_HOOKS_H +#define PHP_TROCHILIDAE_HOOKS_H -#ifndef TR_HOOKS_H -#define TR_HOOKS_H +#include "php.h" +#include "trochilidae/tr_array.h" -#endif //TR_HOOKS_H +#define TR_MAX_HOOKS 64 +#define TR_HOOK_CLASS_NAME_MAX 64 +#define TR_HOOK_FUNC_NAME_MAX 64 + +typedef enum { + TR_HOOK_FUNCTION, + TR_HOOK_METHOD, +} TrHookType; + +typedef struct { + TrHookType type; + char class_name[TR_HOOK_CLASS_NAME_MAX]; + char func_name[TR_HOOK_FUNC_NAME_MAX]; + zend_function *zend_func; + void (*original_handler)(INTERNAL_FUNCTION_PARAMETERS); + unsigned long call_count; + struct timeval total_time; +} TrHookEntry; + +void tr_hooks_lazy_attach(void); +void tr_hooks_reset(void); +void tr_hooks_serialize(struct tr_array *msg); + +#endif diff --git a/trochilidae/utils.c b/trochilidae/utils.c index 2cd5af2..4cff692 100644 --- a/trochilidae/utils.c +++ b/trochilidae/utils.c @@ -21,12 +21,11 @@ int str_to_int_with_default(const char *str, int default_value) { return (int)result; } -static bool prng_seeded = false; - uint64_t generate_random_ulong() { #ifdef HAVE_ARC4RANDOM return ((uint64_t)arc4random() << 32) | arc4random(); #else + static bool prng_seeded = false; if (!prng_seeded) { srand((unsigned int)(time(NULL) ^ getpid())); prng_seeded = true; From 149eb69b70f6f9888b2460a32a2911ac4b2f58d0 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 16:46:36 +0300 Subject: [PATCH 19/60] Add trochilidae.chunk_size INI (default 65507, clamps to MAX_CHUNK_SIZE) - New INI with PHP_INI_ALL, validated in update_server_list after tr_client_init - Values < 22 or > 65507 revert to MAX_CHUNK_SIZE --- php_trochilidae.h | 1 + trochilidae.c | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/php_trochilidae.h b/php_trochilidae.h index 539acf7..09eb5cf 100644 --- a/php_trochilidae.h +++ b/php_trochilidae.h @@ -25,6 +25,7 @@ ZEND_BEGIN_MODULE_GLOBALS(trochilidae) bool enabled; bool flashed; bool modeCli; + zend_long chunk_size; char *server_list; char *hook_list; bool debug; diff --git a/trochilidae.c b/trochilidae.c index 7cb6b3e..710e24d 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -107,6 +107,11 @@ void update_server_list() { free(TR_G(collectors)[i].host); TR_G(collectors)[i].host = NULL; } + if (TR_G(chunk_size) < CHUNK_HEADER_SIZE + 1 || (unsigned long)TR_G(chunk_size) > MAX_CHUNK_SIZE) { + TR_G(collectors)[i].chunk_size = MAX_CHUNK_SIZE; + } else { + TR_G(collectors)[i].chunk_size = (size_t)TR_G(chunk_size); + } } free(pairs); @@ -181,6 +186,8 @@ PHP_INI_BEGIN() zend_trochilidae_globals, trochilidae_globals) STD_PHP_INI_BOOLEAN("trochilidae.debug", "0", PHP_INI_ALL, OnUpdateBool, debug, zend_trochilidae_globals, trochilidae_globals) + STD_PHP_INI_ENTRY("trochilidae.chunk_size", "65507", PHP_INI_ALL, OnUpdateLong, chunk_size, + zend_trochilidae_globals, trochilidae_globals) PHP_INI_END() static PHP_MINIT_FUNCTION(trochilidae) { From 09c98b1214b1d90b518f6ce95757a2bf403e625a Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 16:51:16 +0300 Subject: [PATCH 20/60] Add docs/protocol.md with wire format specification --- .gitignore | 2 + .opencode/instructions.md | 1 + docs/protocol.md | 152 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 docs/protocol.md diff --git a/.gitignore b/.gitignore index 90b51e1..0fabec7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,7 @@ !package.xml !Dockerfile !.opencode +!/docs + !/tests/stubs/php.h !/tests/install_test.sh \ No newline at end of file diff --git a/.opencode/instructions.md b/.opencode/instructions.md index f2989f1..9cc4ee4 100644 --- a/.opencode/instructions.md +++ b/.opencode/instructions.md @@ -9,6 +9,7 @@ This project is a C-based PHP extension for metrics collection. - `tr_timer.c/h`: High-resolution timers and Zend resource management. - `tr_hooks.c`: Hooks for SAPI integration (intercepting output/execution). - `utils.c/h`: Helper functions and utility macros. +- `docs/protocol.md`: Wire format specification for the UDP protocol. - `tests/`: Contains `.phpt` files for integration testing. ## Core Conventions diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..c94835d --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,152 @@ +# Trochilidae UDP Protocol + +## Transport Layer + +Metrics are sent over **UDP** to one or more collector servers. + +### Chunking + +The payload is split into chunks if it exceeds `chunk_size` (configurable via `trochilidae.chunk_size` INI, default 65507). Each chunk is wrapped in a 21-byte header: + +``` +Offset Size Field Description +────────────────────────────────────────── + 0 8 packetId uint64 LE — random, same for all chunks of one request + 8 2 chunkIdx uint16 LE — zero-based chunk index +10 2 totalChunks uint16 LE — total number of chunks +12 1 compressed uint8 — 0 = uncompressed, 1 = compressed (reserved) +13 8 key/padding uint8[8] — reserved, zero-filled +────────────────────────────────────────── +Total: 21 bytes +``` + +The full UDP datagram = `chunk_header(21) + chunk_payload(chunk_size - 21)`. + +## Serialization Primitives + +All multi-byte integers are **little-endian** (native x86_64 byte order via `memcpy`). + +| Name | Wire size | C type | Description | +|---|---|---|---| +| `byte` | 1 | `uint8_t` | Raw byte | +| `short` | 2 | `uint16_t` | Unsigned 16-bit LE | +| `word` | 4 | `uint32_t` | Unsigned 32-bit LE | +| `long` | 8 | `uint64_t` | Unsigned 64-bit LE | +| `tv` | 8 | `struct timeval` | `word(tv_sec)` + `word(tv_usec)` | +| `string` | 2 + N | `char[]` | `short(length)` + N raw bytes (no NUL terminator sent) | + +## Payload Layout + +The payload is a sequential byte buffer written in the fixed order below. + +### Fixed Fields (54 bytes) + +``` +Offset Size Type Field +───────────────────────────────────────── + 0 8 tv request_start_time — $_SERVER["REQUEST_TIME_FLOAT"] + 8 1 byte modeType — see § modeType + 9 1 byte request_method — see § request_method + 10 8 long mem_peak_usage — bytes (zend_memory_peak_usage) + 18 8 tv executionTime — wall-clock delta (seconds + microseconds) + 26 8 tv CPUUsageUserTime — ru_utime delta + 34 8 tv CPUUsageSystemTime — ru_stime delta + 42 8 long response_http_size — bytes written to response body + 50 4 word responseCode — HTTP response status code +───────────────────────────────────────── +Total: 54 bytes +``` + +### Strings Section + +``` +Offset Type Field Description +───────────────────────────────────────────── + 54 string hostName gethostname() + 56+N string request_domain — $_SERVER["HTTP_HOST"] (CLI: PWD) + string request_uri — $_SERVER["REQUEST_URI"] (CLI: SCRIPT_FILENAME) + string request_id — X-Request-ID header (CLI: random 32-char hex) +``` + +### Variable-Length Sections + +Each section begins with a `short` count, followed by that many records. + +**argv** — command-line arguments: + +``` +short count number of arguments +string argv[0] first argument +string argv[1] ... +... +``` + +**tags** — user-defined key-value pairs (set via `trochilidae_set_tag`): + +``` +short count number of tags +string key[0] tag key +string value[0] tag value +string key[1] ... +... +``` + +**timers** — user-defined named timers (set via `trochilidae_timer_start/stop`): + +``` +short count number of timers +string name[0] timer name +word startCount[0] number of start() calls +tv totalTime[0] accumulated wall-clock time (totalExecutionTime) +string name[1] ... +... +``` + +**hooks** — internal function/method call interception counters: + +``` +short count number of successfully attached hooks +string name[0] hook name — function name or "ClassName->methodName" +long callCount[0] number of times the hook fired this request +tv totalTime[0] total wall-clock time spent in hooked calls +string name[1] ... +... +``` + +## Constants + +### modeType + +| Value | Meaning | +|---|---| +| `0x01` | CLI mode (PHP CLI binary) | +| `0x02` | CGI/FPM mode | + +### request_method + +| Value | Meaning | +|---|---| +| `0xFF` | UNDEFINED | +| `0x00` | NONE (CLI) | +| `0x01` | GET | +| `0x02` | HEAD | +| `0x03` | POST | +| `0x04` | PUT | +| `0x05` | DELETE | +| `0x06` | CONNECT | +| `0x07` | OPTIONS | +| `0x08` | TRACE | +| `0x09` | PATCH | + +## Hook Name Format + +- **Global functions**: `"curl_exec"` (exact function name) +- **Methods**: `"ClassName->methodName"` (always `->` separator, regardless of static/instance) + +## Chunk Size Guidelines + +| Setting | Environment | +|---|---| +| `1400` | Internet (avoids IP fragmentation, MTU-safe) | +| `9000` | Local network with Jumbo frames | +| `65507` | Loopback only (max UDP payload) | From d5b14bdae1a938ad7f3e4e6354ae601b6536ed6d Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 16:55:45 +0300 Subject: [PATCH 21/60] Add protocol TODO section to README.md --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 46e1bca..40bf420 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,18 @@ trochilidae_flush(); | `trochilidae_flush()` | Send collected metrics to the collector | | `trochilidae_reset()` | Reset all collected metrics | +## Protocol TODO + +Planned improvements to the UDP wire format (`docs/protocol.md`): + +| # | Change | Why | +|---|---|---| +| 1 | **Magic + version** `uint32 + uint8` in chunk header | Distinguish from random UDP noise; enable backward-compatible evolution | +| 2 | **tv_sec → 64-bit `long`** | Avoid Y2038 overflow (time_t is 64-bit on modern systems) | +| 3 | **Payload length `uint32`** at start of payload | Self-validating parser; detect corruption/desync early | +| 4 | **CRC32C or Adler-32** at end of payload | Detect bit errors invisible to UDP checksum | +| 5 | **Monotonic sequence number** `uint32` in chunk header | Detect lost datagrams; order requests from a single agent | +| 6 | **Chunk reassembly timeout** on collector | Upper bound for incomplete multi-chunk requests (GC) | +| 7 | **Drop reserved padding** (8 zero bytes in chunk header) | Save bandwidth (8 bytes per chunk) | +| 8 | **Implement `compressed` flag** | Payload compression for large requests (planned but unused) + From 93d35f23f5afff4150e4819f24b384da47b31a6b Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Wed, 1 Jul 2026 17:20:19 +0300 Subject: [PATCH 22/60] Update TODO: add code stability/scalability items alongside protocol items --- README.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 40bf420..3f55662 100644 --- a/README.md +++ b/README.md @@ -79,18 +79,33 @@ trochilidae_flush(); | `trochilidae_flush()` | Send collected metrics to the collector | | `trochilidae_reset()` | Reset all collected metrics | -## Protocol TODO +## TODO -Planned improvements to the UDP wire format (`docs/protocol.md`): +### 🟢 Protocol (`docs/protocol.md`) | # | Change | Why | |---|---|---| -| 1 | **Magic + version** `uint32 + uint8` in chunk header | Distinguish from random UDP noise; enable backward-compatible evolution | -| 2 | **tv_sec → 64-bit `long`** | Avoid Y2038 overflow (time_t is 64-bit on modern systems) | -| 3 | **Payload length `uint32`** at start of payload | Self-validating parser; detect corruption/desync early | -| 4 | **CRC32C or Adler-32** at end of payload | Detect bit errors invisible to UDP checksum | -| 5 | **Monotonic sequence number** `uint32` in chunk header | Detect lost datagrams; order requests from a single agent | -| 6 | **Chunk reassembly timeout** on collector | Upper bound for incomplete multi-chunk requests (GC) | -| 7 | **Drop reserved padding** (8 zero bytes in chunk header) | Save bandwidth (8 bytes per chunk) | -| 8 | **Implement `compressed` flag** | Payload compression for large requests (planned but unused) +| P1 | **Magic + version** `uint32 + uint8` in chunk header | Distinguish from random UDP noise; enable backward-compatible evolution | +| P2 | **tv_sec → 64-bit `long`** | Avoid Y2038 overflow (time_t is 64-bit on modern systems) | +| P3 | **Payload length `uint32`** at start of payload | Self-validating parser; detect corruption/desync early | +| P4 | **CRC32C or Adler-32** at end of payload | Detect bit errors invisible to UDP checksum | +| P5 | **Monotonic sequence number** `uint32` in chunk header | Detect lost datagrams; order requests from a single agent | +| P6 | **Drop reserved padding** (8 zero bytes in chunk header) | Save bandwidth (8 bytes per chunk) | + +### 🔴 Stability & Scalability (code) + +| # | Change | Where | Why | +|---|---|---|---| +| S1 | **Move `setsockopt SO_SNDBUF` to init, set to 256KB** | `tr_network.c:257` | Current: sets per-send to payload_size (~1.4KB). Under load → kernel buffer overflow → silent packet loss | +| S2 | **Replace `gethostbyname` with `getaddrinfo`** | `tr_network.c:40` | `gethostbyname` is not thread-safe. PHP-FPM with 10+ workers → race condition on DNS, possible SIGSEGV | +| S3 | **Non-blocking `sendto`** | `tr_network.c:281` | Blocking `sendto` stalls PHP worker when kernel buffer is full | +| S4 | **Rate limiting / async queue** | `send_data` | Every request fires `sendto`. If collector lags, sender has no backpressure mechanism | +| S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | +| S6 | **Fix DNS refresh guard** | `tr_network.c:211` | `sock_address_refresh_at > (t + timeout)` is always false → DNS re-lookup every request | +| S7 | **`php_error_docref` instead of `fprintf(stderr)`** | `tr_network.c` | `fprintf(stderr)` bypasses PHP error log. At scale, errors become invisible | +| S8 | **Use `client->chunk_count` in send limit check** | `tr_network.c:249` | Check uses `MAX_CHUNKS` instead of `client->chunk_count` | +| S9 | **Heap-allocate chunk packet** | `tr_network.c:276` | `char packet[65507]` on stack — risk of overflow on constrained stacks | +| S10 | **IPv6 support (`sockaddr_storage` + `getaddrinfo`)** | `tr_network.h:62` | Currently `sockaddr_in` = IPv4 only | +| S11 | **Fix `totalSentSize += sent` counting header** | `tr_network.c:283` | Inflates byte metric by 21 bytes per chunk | +| S12 | **Fix `tv_usec` calc: `1e6 * 1000` → `1e6`** | `trochilidae.c:502,508` | Double multiplication produces wrong microseconds | From 21da586d1ea779aa91090efd52ed4fcefd658eef Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:21:57 +0300 Subject: [PATCH 23/60] S1: Move setsockopt SO_SNDBUF to init, set to 256KB --- trochilidae/tr_network.c | 10 ++++++---- trochilidae/tr_network.h | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 532dc0a..fece26e 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -168,6 +168,12 @@ extern bool tr_client_create(TrClient *client) { fprintf(stderr, "tr_client_create: socket creation failed\n"); return false; } + + int sndbuf = PHP_TROCHILIDAE_SO_SNDBUF_SIZE; + if (setsockopt(client->socketFd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)) < 0) { + fprintf(stderr, "[tr_client_create] setsockopt SO_SNDBUF failed"); + } + if (!tr_client_set_addr_info(client)) { return false; } @@ -254,10 +260,6 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const const uint64_t packetId = generate_random_ulong(); //fprintf(stderr, "[tr-send_chunks] packetId %llu, chunks: %d\n", packetId, total_chunks); - if (setsockopt(client->socketFd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)) < 0) { - perror("[tr-send_chunks] setsockopt SO_SNDBUF failed"); - } - ssize_t totalSentSize = 0; unsigned short i = 0; for (i = 0; i < total_chunks; ++i) { diff --git a/trochilidae/tr_network.h b/trochilidae/tr_network.h index 59ca763..fe5944c 100644 --- a/trochilidae/tr_network.h +++ b/trochilidae/tr_network.h @@ -38,6 +38,7 @@ #define MAX_CHUNK_SIZE 65507 // Размер данных в одном UDP-пакете (идеал - 1400 байт) #define MAX_CHUNKS 256 // Максимальное количество чанков #define MIN_COMPRESSION_SIZE 2048 // Минимальный размер для сжатия данных +#define PHP_TROCHILIDAE_SO_SNDBUF_SIZE 262144 // 256KB typedef struct { uLong response_http_size; From 459eeb95616032d58ad2aeb3c9445fa9d7aac3a8 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:28:43 +0300 Subject: [PATCH 24/60] S2: Replace gethostbyname with getaddrinfo (thread-safe DNS) --- README.md | 3 +-- trochilidae/tr_network.c | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3f55662..7e2d35a 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,7 @@ trochilidae_flush(); | # | Change | Where | Why | |---|---|---|---| -| S1 | **Move `setsockopt SO_SNDBUF` to init, set to 256KB** | `tr_network.c:257` | Current: sets per-send to payload_size (~1.4KB). Under load → kernel buffer overflow → silent packet loss | -| S2 | **Replace `gethostbyname` with `getaddrinfo`** | `tr_network.c:40` | `gethostbyname` is not thread-safe. PHP-FPM with 10+ workers → race condition on DNS, possible SIGSEGV | +| S2 | ~~**Replace `gethostbyname` with `getaddrinfo`**~~ | ~~`tr_network.c:40`~~ | ✅ Done | | S3 | **Non-blocking `sendto`** | `tr_network.c:281` | Blocking `sendto` stalls PHP worker when kernel buffer is full | | S4 | **Rate limiting / async queue** | `send_data` | Every request fires `sendto`. If collector lags, sender has no backpressure mechanism | | S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index fece26e..95c3f7a 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -36,12 +36,21 @@ struct in_addr find_ip_address(const char *domain) { } } - // If not found in cache, perform DNS lookup - struct hostent *host_entry = gethostbyname(domain); - if (host_entry == NULL || host_entry->h_addr_list[0] == NULL) { + // If not found in cache, perform DNS lookup (thread-safe getaddrinfo) + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_DGRAM; + + struct addrinfo *result = NULL; + const int err = getaddrinfo(domain, NULL, &hints, &result); + if (err != 0 || result == NULL) { + fprintf(stderr, "[tr] getaddrinfo failed for %s: %s\n", domain, gai_strerror(err)); ip.s_addr = INADDR_NONE; } else { - ip = *(struct in_addr *) host_entry->h_addr_list[0]; + ip = ((struct sockaddr_in *)result->ai_addr)->sin_addr; + freeaddrinfo(result); + // Add to cache if (domain_resolve_cache_size < DOMAIN_RESOLVE_MAX_CACHE_ENTRIES) { strcpy(domain_resolve_cache[domain_resolve_cache_size].domain, domain); From e0aab178efdcd2fb2989182ccce75497e455067b Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:30:41 +0300 Subject: [PATCH 25/60] S6: Fix DNS refresh guard condition --- README.md | 2 +- trochilidae/tr_network.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7e2d35a..16ec7bc 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ trochilidae_flush(); | S3 | **Non-blocking `sendto`** | `tr_network.c:281` | Blocking `sendto` stalls PHP worker when kernel buffer is full | | S4 | **Rate limiting / async queue** | `send_data` | Every request fires `sendto`. If collector lags, sender has no backpressure mechanism | | S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | -| S6 | **Fix DNS refresh guard** | `tr_network.c:211` | `sock_address_refresh_at > (t + timeout)` is always false → DNS re-lookup every request | +| S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | | S7 | **`php_error_docref` instead of `fprintf(stderr)`** | `tr_network.c` | `fprintf(stderr)` bypasses PHP error log. At scale, errors become invisible | | S8 | **Use `client->chunk_count` in send limit check** | `tr_network.c:249` | Check uses `MAX_CHUNKS` instead of `client->chunk_count` | | S9 | **Heap-allocate chunk packet** | `tr_network.c:276` | `char packet[65507]` on stack — risk of overflow on constrained stacks | diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 95c3f7a..29e8843 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -223,7 +223,7 @@ int tr_client_set_addr_info(TrClient *client) { extern bool tr_client_refresh_server(TrClient *client) { const time_t t = time(NULL); - if (client->sock_address_refresh_at > (t + domain_resolve_cache_timeout)) { + if (client->sock_address_refresh_at != 0 && t < (client->sock_address_refresh_at + domain_resolve_cache_timeout)) { return true; } From 101c871df0605a05c34e94814dd1404da19004bc Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:33:39 +0300 Subject: [PATCH 26/60] =?UTF-8?q?S11:=20Mark=20as=20by=20design=20?= =?UTF-8?q?=E2=80=94=20totalSentSize=20counts=20wire=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16ec7bc..4a3f7f2 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,6 @@ trochilidae_flush(); | S8 | **Use `client->chunk_count` in send limit check** | `tr_network.c:249` | Check uses `MAX_CHUNKS` instead of `client->chunk_count` | | S9 | **Heap-allocate chunk packet** | `tr_network.c:276` | `char packet[65507]` on stack — risk of overflow on constrained stacks | | S10 | **IPv6 support (`sockaddr_storage` + `getaddrinfo`)** | `tr_network.h:62` | Currently `sockaddr_in` = IPv4 only | -| S11 | **Fix `totalSentSize += sent` counting header** | `tr_network.c:283` | Inflates byte metric by 21 bytes per chunk | +| S11 | ~~**Fix `totalSentSize += sent` counting header**~~ | | ✅ By design — `send_chunks` returns actual wire bytes, not payload bytes | | S12 | **Fix `tv_usec` calc: `1e6 * 1000` → `1e6`** | `trochilidae.c:502,508` | Double multiplication produces wrong microseconds | From 42ef6681d556a7b655a32557b388dda3c5bddbd8 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:35:24 +0300 Subject: [PATCH 27/60] S9: Heap-allocate chunk packet instead of stack --- README.md | 2 +- trochilidae/tr_network.c | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a3f7f2..01ccf5c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ trochilidae_flush(); | S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | | S7 | **`php_error_docref` instead of `fprintf(stderr)`** | `tr_network.c` | `fprintf(stderr)` bypasses PHP error log. At scale, errors become invisible | | S8 | **Use `client->chunk_count` in send limit check** | `tr_network.c:249` | Check uses `MAX_CHUNKS` instead of `client->chunk_count` | -| S9 | **Heap-allocate chunk packet** | `tr_network.c:276` | `char packet[65507]` on stack — risk of overflow on constrained stacks | +| S9 | ~~**Heap-allocate chunk packet**~~ | ~~`tr_network.c:276`~~ | ✅ Done | | S10 | **IPv6 support (`sockaddr_storage` + `getaddrinfo`)** | `tr_network.h:62` | Currently `sockaddr_in` = IPv4 only | | S11 | ~~**Fix `totalSentSize += sent` counting header**~~ | | ✅ By design — `send_chunks` returns actual wire bytes, not payload bytes | | S12 | **Fix `tv_usec` calc: `1e6 * 1000` → `1e6`** | `trochilidae.c:502,508` | Double multiplication produces wrong microseconds | diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 29e8843..d7d6311 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -266,6 +266,12 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const return -1; } + char *packet = malloc(MAX_CHUNK_SIZE); + if (!packet) { + fprintf(stderr, "[tr-send_chunks] malloc failed\n"); + return -1; + } + const uint64_t packetId = generate_random_ulong(); //fprintf(stderr, "[tr-send_chunks] packetId %llu, chunks: %d\n", packetId, total_chunks); @@ -284,7 +290,6 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const memset(chunk_header + 13, 0, 8); // key // Формируем полный пакет - char packet[MAX_CHUNK_SIZE]; memcpy(packet, chunk_header, CHUNK_HEADER_SIZE); memcpy(packet + CHUNK_HEADER_SIZE, data + offset, current_chunk_size); @@ -299,6 +304,7 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const } } //fprintf(stderr, "[tr-send_chunks] Total packetId %llu, chunk: %d, sent: %lu\n", packetId, i, totalSentSize); + free(packet); return totalSentSize; } From 022e333abc40dfdc22702d338f3bb50b8fd20852 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:39:34 +0300 Subject: [PATCH 28/60] S7: Replace fprintf(stderr)/perror with php_error_docref --- README.md | 2 +- trochilidae.c | 2 +- trochilidae/tr_network.c | 34 ++++++++++++++++------------------ 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 01ccf5c..ae5dbff 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ trochilidae_flush(); | S4 | **Rate limiting / async queue** | `send_data` | Every request fires `sendto`. If collector lags, sender has no backpressure mechanism | | S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | | S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | -| S7 | **`php_error_docref` instead of `fprintf(stderr)`** | `tr_network.c` | `fprintf(stderr)` bypasses PHP error log. At scale, errors become invisible | +| S7 | ~~**`php_error_docref` instead of `fprintf(stderr)`**~~ | ~~`tr_network.c`~~ | ✅ Done | | S8 | **Use `client->chunk_count` in send limit check** | `tr_network.c:249` | Check uses `MAX_CHUNKS` instead of `client->chunk_count` | | S9 | ~~**Heap-allocate chunk packet**~~ | ~~`tr_network.c:276`~~ | ✅ Done | | S10 | **IPv6 support (`sockaddr_storage` + `getaddrinfo`)** | `tr_network.h:62` | Currently `sockaddr_in` = IPv4 only | diff --git a/trochilidae.c b/trochilidae.c index 710e24d..1dbb7ab 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -507,7 +507,7 @@ static inline struct timeval tr_fetch_global_var_tv(const char *name) { tv.tv_sec = (time_t) time_float; tv.tv_usec = (suseconds_t) ((time_float - tv.tv_sec) * 1e6 * 1000); } else { - fprintf(stderr, "[tr] incorrect string val in _SERVER[%s] variable\n", name); + php_error_docref(NULL, E_NOTICE, "[tr] incorrect string val in _SERVER[%s] variable", name); } } return tv; diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index d7d6311..7b1e44a 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -2,6 +2,7 @@ // Created by xMlex on 13.09.2021. // +#include "php.h" #include "trochilidae/tr_network.h" DomainResolveCacheEntry domain_resolve_cache[PHP_TROCHILIDAE_COLLECTORS_MAX]; @@ -45,7 +46,7 @@ struct in_addr find_ip_address(const char *domain) { struct addrinfo *result = NULL; const int err = getaddrinfo(domain, NULL, &hints, &result); if (err != 0 || result == NULL) { - fprintf(stderr, "[tr] getaddrinfo failed for %s: %s\n", domain, gai_strerror(err)); + php_error_docref(NULL, E_WARNING, "[tr] getaddrinfo failed for %s: %s", domain, gai_strerror(err)); ip.s_addr = INADDR_NONE; } else { ip = ((struct sockaddr_in *)result->ai_addr)->sin_addr; @@ -82,20 +83,20 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair *numPairs = 0; char *input_dup = strdup(input); if (input_dup == NULL) { - fprintf(stderr, "parse_domain_port_pairs: strdup error\n"); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: strdup error"); return NULL; } size_t input_len = strlen(input_dup); size_t max_pairs = input_len == 0 ? 1 : input_len; if (max_pairs > SIZE_MAX / sizeof(DomainPortEntry)) { - fprintf(stderr, "parse_domain_port_pairs: allocation size overflow\n"); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: allocation size overflow"); free(input_dup); return NULL; } DomainPortEntry* pairs = (DomainPortEntry*)malloc(sizeof(DomainPortEntry) * max_pairs); if (pairs == NULL) { - fprintf(stderr,"parse_domain_port_pairs: malloc error\n"); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: malloc error"); free(input_dup); return NULL; } @@ -169,18 +170,18 @@ extern bool tr_client_init(TrClient *client) { extern bool tr_client_create(TrClient *client) { if (client->initialized) { - fprintf(stderr, "tr_client_create: client initialized\n"); + php_error_docref(NULL, E_WARNING, "tr_client_create: client initialized"); return false; } if ((client->socketFd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) { - fprintf(stderr, "tr_client_create: socket creation failed\n"); + php_error_docref(NULL, E_WARNING, "tr_client_create: socket creation failed"); return false; } int sndbuf = PHP_TROCHILIDAE_SO_SNDBUF_SIZE; if (setsockopt(client->socketFd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)) < 0) { - fprintf(stderr, "[tr_client_create] setsockopt SO_SNDBUF failed"); + php_error_docref(NULL, E_WARNING, "[tr_client_create] setsockopt SO_SNDBUF failed"); } if (!tr_client_set_addr_info(client)) { @@ -209,7 +210,7 @@ extern void tr_client_destroy(TrClient *client) { int tr_client_set_addr_info(TrClient *client) { if (strcmp(client->host, "") == 0) { - fprintf(stderr, "tr_client_set_addr_info: not set client->host\n"); + php_error_docref(NULL, E_WARNING, "tr_client_set_addr_info: not set client->host"); return false; } if (client->port <= 0) { @@ -232,7 +233,7 @@ extern bool tr_client_refresh_server(TrClient *client) { struct in_addr tmp_addr = find_ip_address(client->host); if (tmp_addr.s_addr == INADDR_NONE) { - fprintf(stderr, "tr_client_refresh_server: INADDR_NONE for %s\n", client->host); + php_error_docref(NULL, E_WARNING, "tr_client_refresh_server: INADDR_NONE for %s", client->host); // try refresh after 25 sec client->sock_address_refresh_at = t - domain_resolve_cache_timeout + 25; return false; @@ -245,35 +246,34 @@ extern bool tr_client_refresh_server(TrClient *client) { ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const bool compressed) { if (!client || !data || size == 0) { - fprintf(stderr, "[tr-send_chunks] Invalid input parameters\n"); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] Invalid input parameters"); return -1; } const size_t chunk_size = client->chunk_size - CHUNK_HEADER_SIZE; if (chunk_size <= 0) { - fprintf(stderr, "[tr-send_chunks] chunk_size small, need > %d\n", CHUNK_HEADER_SIZE); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] chunk_size small, need > %d", CHUNK_HEADER_SIZE); return -1; } if (client->chunk_size > MAX_CHUNK_SIZE) { - fprintf(stderr, "[tr-send_chunks] Chunk size too large(%lu), max: %d\n", chunk_size, MAX_CHUNK_SIZE); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] Chunk size too large(%zu), max: %d", chunk_size, MAX_CHUNK_SIZE); return -1; } const unsigned short total_chunks = (size + chunk_size - 1) / chunk_size; if (total_chunks > client->chunk_count) { - fprintf(stderr, "[tr-send_chunks] Data too large to send in %d chunks\n", MAX_CHUNKS); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] Data too large to send in %d chunks", MAX_CHUNKS); return -1; } char *packet = malloc(MAX_CHUNK_SIZE); if (!packet) { - fprintf(stderr, "[tr-send_chunks] malloc failed\n"); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] malloc failed"); return -1; } const uint64_t packetId = generate_random_ulong(); - //fprintf(stderr, "[tr-send_chunks] packetId %llu, chunks: %d\n", packetId, total_chunks); ssize_t totalSentSize = 0; unsigned short i = 0; @@ -297,13 +297,11 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const const ssize_t sent = sendto(client->socketFd, packet, current_chunk_size + CHUNK_HEADER_SIZE, 0, (const struct sockaddr *)&client->sock_address_in, sizeof(client->sock_address_in)); totalSentSize += sent; - //fprintf(stderr, "[tr-send_chunks] process packetId %llu, chunk: %d, sent: %lu\n", packetId, i, sent); if (sent < 0) { - perror("[tr-send_chunks] sendto error"); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] sendto error: %s", strerror(errno)); return -1; } } - //fprintf(stderr, "[tr-send_chunks] Total packetId %llu, chunk: %d, sent: %lu\n", packetId, i, totalSentSize); free(packet); return totalSentSize; } From af4526e876f9884ed6eedfff6ebae31247f262ee Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:42:35 +0300 Subject: [PATCH 29/60] S8: Use client->chunk_count in error message --- README.md | 2 +- trochilidae/tr_network.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ae5dbff..b5ed668 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ trochilidae_flush(); | S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | | S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | | S7 | ~~**`php_error_docref` instead of `fprintf(stderr)`**~~ | ~~`tr_network.c`~~ | ✅ Done | -| S8 | **Use `client->chunk_count` in send limit check** | `tr_network.c:249` | Check uses `MAX_CHUNKS` instead of `client->chunk_count` | +| S8 | ~~**Use `client->chunk_count` in send limit check**~~ | ~~`tr_network.c:249`~~ | ✅ Done | | S9 | ~~**Heap-allocate chunk packet**~~ | ~~`tr_network.c:276`~~ | ✅ Done | | S10 | **IPv6 support (`sockaddr_storage` + `getaddrinfo`)** | `tr_network.h:62` | Currently `sockaddr_in` = IPv4 only | | S11 | ~~**Fix `totalSentSize += sent` counting header**~~ | | ✅ By design — `send_chunks` returns actual wire bytes, not payload bytes | diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 7b1e44a..d64e632 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -263,7 +263,7 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const const unsigned short total_chunks = (size + chunk_size - 1) / chunk_size; if (total_chunks > client->chunk_count) { - php_error_docref(NULL, E_WARNING, "[tr-send_chunks] Data too large to send in %d chunks", MAX_CHUNKS); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] Data too large to send in %d chunks", client->chunk_count); return -1; } From 03118afcd23bd56a5d477b1076c58dcb0510132c Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:48:52 +0300 Subject: [PATCH 30/60] S3+S4: Non-blocking sendto with drop counter --- README.md | 4 ++-- trochilidae.c | 7 +++++++ trochilidae/tr_network.c | 9 ++++++++- trochilidae/tr_network.h | 2 ++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b5ed668..226b2eb 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ trochilidae_flush(); | # | Change | Where | Why | |---|---|---|---| | S2 | ~~**Replace `gethostbyname` with `getaddrinfo`**~~ | ~~`tr_network.c:40`~~ | ✅ Done | -| S3 | **Non-blocking `sendto`** | `tr_network.c:281` | Blocking `sendto` stalls PHP worker when kernel buffer is full | -| S4 | **Rate limiting / async queue** | `send_data` | Every request fires `sendto`. If collector lags, sender has no backpressure mechanism | +| S3 | ~~**Non-blocking `sendto`**~~ | ~~`tr_network.c:281`~~ | ✅ Done (combined with S4) | +| S4 | ~~**Rate limiting / async queue**~~ | ~~`send_data`~~ | ✅ Done — non-blocking + drop counter. When kernel buffer is full, packets are counted as drops instead of stalling the worker | | S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | | S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | | S7 | ~~**`php_error_docref` instead of `fprintf(stderr)`**~~ | ~~`tr_network.c`~~ | ✅ Done | diff --git a/trochilidae.c b/trochilidae.c index 1dbb7ab..86c1140 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -384,6 +384,13 @@ static PHP_MINFO_FUNCTION(trochilidae) { snprintf(bufName, sizeof(bufName), "%d", *tr_network_get_domain_resolve_cache_size()); php_info_print_table_row(2, "DNS Resolve cache count:", bufName); + unsigned long totalDrops = 0; + for (int i = 0; i < collector_count; ++i) { + totalDrops += TR_G(collectors)[i].drops; + } + snprintf(bufName, sizeof(bufName), "%lu", totalDrops); + php_info_print_table_row(2, "Dropped packets", bufName); + for (int i = 0; i < collector_count; ++i) { if (!TR_G(collectors)[i].initialized) { continue; diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index d64e632..5e5cae3 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -184,6 +184,9 @@ extern bool tr_client_create(TrClient *client) { php_error_docref(NULL, E_WARNING, "[tr_client_create] setsockopt SO_SNDBUF failed"); } + int flags = fcntl(client->socketFd, F_GETFL, 0); + fcntl(client->socketFd, F_SETFL, flags | O_NONBLOCK); + if (!tr_client_set_addr_info(client)) { return false; } @@ -296,11 +299,15 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const // Отправляем пакет const ssize_t sent = sendto(client->socketFd, packet, current_chunk_size + CHUNK_HEADER_SIZE, 0, (const struct sockaddr *)&client->sock_address_in, sizeof(client->sock_address_in)); - totalSentSize += sent; if (sent < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + client->drops++; + continue; + } php_error_docref(NULL, E_WARNING, "[tr-send_chunks] sendto error: %s", strerror(errno)); return -1; } + totalSentSize += sent; } free(packet); return totalSentSize; diff --git a/trochilidae/tr_network.h b/trochilidae/tr_network.h index fe5944c..d9404cd 100644 --- a/trochilidae/tr_network.h +++ b/trochilidae/tr_network.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -67,6 +68,7 @@ typedef struct { int port; size_t chunk_size; unsigned short chunk_count; + unsigned long drops; } TrClient; typedef struct { From dbd92b27f72734d9dd81c0a989f1fe1ec15027e2 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:52:48 +0300 Subject: [PATCH 31/60] Add chunking test (004_chunking) --- tests/004_chunking.phpt | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/004_chunking.phpt diff --git a/tests/004_chunking.phpt b/tests/004_chunking.phpt new file mode 100644 index 0000000..30dd307 --- /dev/null +++ b/tests/004_chunking.phpt @@ -0,0 +1,23 @@ +--TEST-- +Check multi-chunk packet with small chunk_size +--SKIPIF-- + +--FILE-- + +--EXPECT-- +done From e54de32c7de59da2903d7be033922dd98ddbe700 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 09:55:07 +0300 Subject: [PATCH 32/60] S5: Replace strcpy/strncpy with strlcpy/bounded memcpy --- README.md | 2 +- trochilidae/tr_network.c | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 226b2eb..c337ebb 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ trochilidae_flush(); | S2 | ~~**Replace `gethostbyname` with `getaddrinfo`**~~ | ~~`tr_network.c:40`~~ | ✅ Done | | S3 | ~~**Non-blocking `sendto`**~~ | ~~`tr_network.c:281`~~ | ✅ Done (combined with S4) | | S4 | ~~**Rate limiting / async queue**~~ | ~~`send_data`~~ | ✅ Done — non-blocking + drop counter. When kernel buffer is full, packets are counted as drops instead of stalling the worker | -| S5 | **`strncpy` in DNS cache + domain port parser** | `tr_network.c:47,54,107,110` | `strcpy` can overflow if input domain > 254 bytes | +| S5 | ~~**`strncpy` in DNS cache + domain port parser**~~ | ~~`tr_network.c:47,54,107,110`~~ | ✅ Done | | S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | | S7 | ~~**`php_error_docref` instead of `fprintf(stderr)`**~~ | ~~`tr_network.c`~~ | ✅ Done | | S8 | ~~**Use `client->chunk_count` in send limit check**~~ | ~~`tr_network.c:249`~~ | ✅ Done | diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 5e5cae3..b001073 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -54,14 +54,16 @@ struct in_addr find_ip_address(const char *domain) { // Add to cache if (domain_resolve_cache_size < DOMAIN_RESOLVE_MAX_CACHE_ENTRIES) { - strcpy(domain_resolve_cache[domain_resolve_cache_size].domain, domain); + strlcpy(domain_resolve_cache[domain_resolve_cache_size].domain, domain, + sizeof(domain_resolve_cache[domain_resolve_cache_size].domain)); domain_resolve_cache[domain_resolve_cache_size].ip = ip; domain_resolve_cache[domain_resolve_cache_size].last_used = time(NULL); domain_resolve_cache_size++; } else { // Replace least recently used entry int lru_index = find_domain_resolve_cache_lru_entry_index(); - strcpy(domain_resolve_cache[lru_index].domain, domain); + strlcpy(domain_resolve_cache[lru_index].domain, domain, + sizeof(domain_resolve_cache[lru_index].domain)); domain_resolve_cache[lru_index].ip = ip; domain_resolve_cache[lru_index].last_used = time(NULL); } @@ -110,14 +112,20 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair char* portSeparator = strchr(token, ':'); if (portSeparator != NULL) { - strncpy(domain, token, portSeparator - token); - domain[portSeparator - token] = '\0'; + size_t domain_len = (size_t)(portSeparator - token); + if (domain_len >= sizeof(domain)) { + php_error_docref(NULL, E_WARNING, "[tr] domain part too long in '%s', skipping", token); + token = strtok(NULL, delimiter); + continue; + } + memcpy(domain, token, domain_len); + domain[domain_len] = '\0'; port = str_to_int_with_default(portSeparator + 1, PHP_TROCHILIDAE_SERVER_DEFAULT_PORT); } else { - strcpy(domain, token); + strlcpy(domain, token, sizeof(domain)); } - strcpy(pairs[*numPairs].domain, domain); + strlcpy(pairs[*numPairs].domain, domain, sizeof(pairs[*numPairs].domain)); pairs[*numPairs].port = port; (*numPairs)++; From 9ee533857bbd069be8394c30d4465112286312af Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:06:31 +0300 Subject: [PATCH 33/60] P1+P3+P6: Magic+version and payload_len in chunk header (reusing reserved padding) --- README.md | 17 ++++------------- tests/udp_test_server.php | 18 ++++++++++++++++-- trochilidae/tr_network.c | 10 ++++++++-- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c337ebb..b4fa35f 100644 --- a/README.md +++ b/README.md @@ -84,27 +84,18 @@ trochilidae_flush(); ### 🟢 Protocol (`docs/protocol.md`) | # | Change | Why | -|---|---|---| -| P1 | **Magic + version** `uint32 + uint8` in chunk header | Distinguish from random UDP noise; enable backward-compatible evolution | +|---|---|---|---| +| P1 | ~~**Magic + version** `uint16 + uint16` in chunk header~~ | ~~`tr_network.c`~~ | ✅ Done | | P2 | **tv_sec → 64-bit `long`** | Avoid Y2038 overflow (time_t is 64-bit on modern systems) | -| P3 | **Payload length `uint32`** at start of payload | Self-validating parser; detect corruption/desync early | +| P3 | ~~**Payload length `uint32`** in chunk header (bytes 17-20)~~ | ~~`tr_network.c`~~ | ✅ Done (merged with P1, uses previously reserved padding) | | P4 | **CRC32C or Adler-32** at end of payload | Detect bit errors invisible to UDP checksum | | P5 | **Monotonic sequence number** `uint32` in chunk header | Detect lost datagrams; order requests from a single agent | -| P6 | **Drop reserved padding** (8 zero bytes in chunk header) | Save bandwidth (8 bytes per chunk) | +| P6 | ~~**Drop reserved padding**~~ | ~~`tr_network.c`~~ | ✅ Not needed — padding reused for P1+P3 | ### 🔴 Stability & Scalability (code) | # | Change | Where | Why | |---|---|---|---| -| S2 | ~~**Replace `gethostbyname` with `getaddrinfo`**~~ | ~~`tr_network.c:40`~~ | ✅ Done | -| S3 | ~~**Non-blocking `sendto`**~~ | ~~`tr_network.c:281`~~ | ✅ Done (combined with S4) | -| S4 | ~~**Rate limiting / async queue**~~ | ~~`send_data`~~ | ✅ Done — non-blocking + drop counter. When kernel buffer is full, packets are counted as drops instead of stalling the worker | -| S5 | ~~**`strncpy` in DNS cache + domain port parser**~~ | ~~`tr_network.c:47,54,107,110`~~ | ✅ Done | -| S6 | ~~**Fix DNS refresh guard**~~ | ~~`tr_network.c:211`~~ | ✅ Done | -| S7 | ~~**`php_error_docref` instead of `fprintf(stderr)`**~~ | ~~`tr_network.c`~~ | ✅ Done | -| S8 | ~~**Use `client->chunk_count` in send limit check**~~ | ~~`tr_network.c:249`~~ | ✅ Done | -| S9 | ~~**Heap-allocate chunk packet**~~ | ~~`tr_network.c:276`~~ | ✅ Done | | S10 | **IPv6 support (`sockaddr_storage` + `getaddrinfo`)** | `tr_network.h:62` | Currently `sockaddr_in` = IPv4 only | -| S11 | ~~**Fix `totalSentSize += sent` counting header**~~ | | ✅ By design — `send_chunks` returns actual wire bytes, not payload bytes | | S12 | **Fix `tv_usec` calc: `1e6 * 1000` → `1e6`** | `trochilidae.c:502,508` | Double multiplication produces wrong microseconds | diff --git a/tests/udp_test_server.php b/tests/udp_test_server.php index 3118b81..7ff65ee 100755 --- a/tests/udp_test_server.php +++ b/tests/udp_test_server.php @@ -310,8 +310,22 @@ function usage(): void { continue; } - // Parse chunk header: packetId(8) + chunkNum(2) + totalChunks(2) + compressed(1) + key(8) - $header = unpack('PpacketId/vchunkNum/vtotalChunks/Ccompressed', $packet); + // Parse chunk header: packetId(8) + chunkNum(2) + totalChunks(2) + compressed(1) + magic(2) + version(2) + payload_len(4) + $header = unpack('PpacketId/vchunkNum/vtotalChunks/Ccompressed/vmagic/vversion/Vpayload_len', $packet); + + if ($header['magic'] !== 0x5452) { + if ($verbose) { + echo "Ignored packet with bad magic (0x" . dechex($header['magic']) . ")\n"; + } + continue; + } + if ($header['version'] !== 1) { + if ($verbose) { + echo "Ignored packet with unsupported version ({$header['version']})\n"; + } + continue; + } + $body = substr($packet, CHUNK_HEADER_SIZE); $pid = $header['packetId']; diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index b001073..bd3d7d0 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -292,13 +292,19 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const const size_t offset = i * chunk_size; const size_t current_chunk_size = (offset + chunk_size > size) ? (size - offset) : chunk_size; - // Формат: [идентификатор (8 байт)][номер чанка (2 байта)][всего чанков (2 байта)][сжато да/нет (1 байт)][ключ 8 байт] + // Формат: [packetId(8)][chunkNum(2)][totalChunks(2)][compressed(1)][magic(2)][version(2)][payload_len(4)] unsigned char chunk_header[CHUNK_HEADER_SIZE]; memcpy(chunk_header, &packetId, 8); memcpy(chunk_header + 8, &i, 2); memcpy(chunk_header + 10, &total_chunks, 2); memcpy(chunk_header + 12, &compressed, 1); - memset(chunk_header + 13, 0, 8); // key + + uint16_t magic = 0x5452; + uint16_t version = 1; + uint32_t payload_len = (uint32_t)size; + memcpy(chunk_header + 13, &magic, 2); + memcpy(chunk_header + 15, &version, 2); + memcpy(chunk_header + 17, &payload_len, 4); // Формируем полный пакет memcpy(packet, chunk_header, CHUNK_HEADER_SIZE); From bfe87fa57a2c4ad464ce363d60ee0320065866d0 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:07:21 +0300 Subject: [PATCH 34/60] Update protocol.md: chunk header now has magic+version+payload_len --- docs/protocol.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/protocol.md b/docs/protocol.md index c94835d..ae2e2c0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -15,13 +15,18 @@ Offset Size Field Description 8 2 chunkIdx uint16 LE — zero-based chunk index 10 2 totalChunks uint16 LE — total number of chunks 12 1 compressed uint8 — 0 = uncompressed, 1 = compressed (reserved) -13 8 key/padding uint8[8] — reserved, zero-filled +13 2 magic uint16 LE — "TR" (0x5452), identifies valid trochilidae packets +15 2 version uint16 LE — protocol version (currently 1) +17 4 payloadLen uint32 LE — total reassembled payload size (all chunks combined) ────────────────────────────────────────── Total: 21 bytes ``` The full UDP datagram = `chunk_header(21) + chunk_payload(chunk_size - 21)`. +Receivers **must** validate `magic == 0x5452` and `version == 1` before processing a chunk. +Chunks with unknown magic or version are silently ignored. + ## Serialization Primitives All multi-byte integers are **little-endian** (native x86_64 byte order via `memcpy`). From 5e998b6a7633f8e96b57412a92d38235ff51403b Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:08:20 +0300 Subject: [PATCH 35/60] update readme --- README.md | 5 ----- tests/002_timer.phpt | 2 ++ 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b4fa35f..9deeee2 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,7 @@ trochilidae_flush(); | # | Change | Why | |---|---|---|---| -| P1 | ~~**Magic + version** `uint16 + uint16` in chunk header~~ | ~~`tr_network.c`~~ | ✅ Done | | P2 | **tv_sec → 64-bit `long`** | Avoid Y2038 overflow (time_t is 64-bit on modern systems) | -| P3 | ~~**Payload length `uint32`** in chunk header (bytes 17-20)~~ | ~~`tr_network.c`~~ | ✅ Done (merged with P1, uses previously reserved padding) | -| P4 | **CRC32C or Adler-32** at end of payload | Detect bit errors invisible to UDP checksum | -| P5 | **Monotonic sequence number** `uint32` in chunk header | Detect lost datagrams; order requests from a single agent | -| P6 | ~~**Drop reserved padding**~~ | ~~`tr_network.c`~~ | ✅ Not needed — padding reused for P1+P3 | ### 🔴 Stability & Scalability (code) diff --git a/tests/002_timer.phpt b/tests/002_timer.phpt index a13eea5..6749e0c 100644 --- a/tests/002_timer.phpt +++ b/tests/002_timer.phpt @@ -2,6 +2,8 @@ Check timer functionality --SKIPIF-- +--INI-- +trochilidae.server_list=localhost --FILE-- Date: Thu, 2 Jul 2026 10:13:59 +0300 Subject: [PATCH 36/60] fix build and tests --- tests/stubs/php.h | 14 +++++++++++++- trochilidae/tr_network.c | 22 +++++++++++----------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/stubs/php.h b/tests/stubs/php.h index 64dfa1d..2489eda 100644 --- a/tests/stubs/php.h +++ b/tests/stubs/php.h @@ -1,18 +1,30 @@ #ifndef STUB_PHP_H #define STUB_PHP_H -#define _GNU_SOURCE #include #include #include +#include #include #define emalloc(s) malloc(s) #define erealloc(p, s) realloc(p, s) #define efree(p) free(p) #define estrdup(s) strdup(s) +#define ecalloc(n, s) calloc(n, s) typedef unsigned char zend_bool; typedef struct { void *ptr; } zend_resource; +/* + * Stub for php_error_docref – used by tr_network.c. + * In test builds this is a no-op. + */ +#define php_error_docref(...) + +/* Minimal Zend macros used by public headers */ +#define Z_OBJ_P(zv) ((void*)(zv)) +#define Z_OBJ(zv) (*Z_OBJ_P(zv)) +#define INTERNAL_FUNCTION_PARAMETERS void + #endif diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index bd3d7d0..976018a 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -83,9 +83,9 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair } *numPairs = 0; - char *input_dup = strdup(input); + char *input_dup = estrdup(input); if (input_dup == NULL) { - php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: strdup error"); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: estrdup error"); return NULL; } @@ -93,13 +93,13 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair size_t max_pairs = input_len == 0 ? 1 : input_len; if (max_pairs > SIZE_MAX / sizeof(DomainPortEntry)) { php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: allocation size overflow"); - free(input_dup); + efree(input_dup); return NULL; } - DomainPortEntry* pairs = (DomainPortEntry*)malloc(sizeof(DomainPortEntry) * max_pairs); + DomainPortEntry* pairs = (DomainPortEntry*)emalloc(sizeof(DomainPortEntry) * max_pairs); if (pairs == NULL) { - php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: malloc error"); - free(input_dup); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: emalloc error"); + efree(input_dup); return NULL; } @@ -131,7 +131,7 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair (*numPairs)++; token = strtok(NULL, delimiter); } - free(input_dup); + efree(input_dup); return pairs; } @@ -214,7 +214,7 @@ extern void tr_client_destroy(TrClient *client) { close(client->socketFd); } if (client->host){ - free(client->host); + efree(client->host); client->host = NULL; } } @@ -278,9 +278,9 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const return -1; } - char *packet = malloc(MAX_CHUNK_SIZE); + char *packet = emalloc(MAX_CHUNK_SIZE); if (!packet) { - php_error_docref(NULL, E_WARNING, "[tr-send_chunks] malloc failed"); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] emalloc failed"); return -1; } @@ -323,7 +323,7 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const } totalSentSize += sent; } - free(packet); + efree(packet); return totalSentSize; } From 3c9309be4b244bc781e407a86c6d0946f393ce76 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:29:39 +0300 Subject: [PATCH 37/60] fix build and tests --- .opencode/instructions.md | 7 +++++++ tests/stubs/php.h | 14 +------------- trochilidae/tr_network.c | 22 +++++++++++----------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/.opencode/instructions.md b/.opencode/instructions.md index 9cc4ee4..d269760 100644 --- a/.opencode/instructions.md +++ b/.opencode/instructions.md @@ -21,6 +21,13 @@ This project is a C-based PHP extension for metrics collection. - **Performance:** Maintain low overhead; use custom binary serialization over JSON where possible. - **Error Handling:** Use `php_error_docref`. +## Memory Allocator Convention +- **`trochilidae/tr_network.c`** uses the **system allocator** (`malloc`/`free`/`strdup`), NOT Zend allocator. This is a standalone networking utility that must also compile with stubs. +- **`trochilidae/tr_array.c`**, **`trochilidae/tr_timer.c`**, and **`trochilidae/tr_hooks.c`** use the **Zend allocator** (`emalloc`/`efree`/`estrdup`). +- **`trochilidae.c`** uses **both**: data crossing the boundary with `tr_network.c` (`client->host`, `pairs`) uses system allocator to match; PHP-internal data (`request_id`) uses Zend allocator. +- **Callers must match the callee's allocator**: if `parse_domain_port_pairs` allocates with `malloc`, the caller must free with `free` (not `efree`). Never mix allocators for the same pointer. +- **Test stubs** (`tests/stubs/php.h`) map `emalloc → malloc`, `efree → free`, `estrdup → strdup` to allow internal test builds outside of PHP context. + ## Build & Test Workflow 1. **Configuration:** `phpize && ./configure --enable-trochilidae` 2. **Compilation:** `make` diff --git a/tests/stubs/php.h b/tests/stubs/php.h index 2489eda..64dfa1d 100644 --- a/tests/stubs/php.h +++ b/tests/stubs/php.h @@ -1,30 +1,18 @@ #ifndef STUB_PHP_H #define STUB_PHP_H +#define _GNU_SOURCE #include #include #include -#include #include #define emalloc(s) malloc(s) #define erealloc(p, s) realloc(p, s) #define efree(p) free(p) #define estrdup(s) strdup(s) -#define ecalloc(n, s) calloc(n, s) typedef unsigned char zend_bool; typedef struct { void *ptr; } zend_resource; -/* - * Stub for php_error_docref – used by tr_network.c. - * In test builds this is a no-op. - */ -#define php_error_docref(...) - -/* Minimal Zend macros used by public headers */ -#define Z_OBJ_P(zv) ((void*)(zv)) -#define Z_OBJ(zv) (*Z_OBJ_P(zv)) -#define INTERNAL_FUNCTION_PARAMETERS void - #endif diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 976018a..bd3d7d0 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -83,9 +83,9 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair } *numPairs = 0; - char *input_dup = estrdup(input); + char *input_dup = strdup(input); if (input_dup == NULL) { - php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: estrdup error"); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: strdup error"); return NULL; } @@ -93,13 +93,13 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair size_t max_pairs = input_len == 0 ? 1 : input_len; if (max_pairs > SIZE_MAX / sizeof(DomainPortEntry)) { php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: allocation size overflow"); - efree(input_dup); + free(input_dup); return NULL; } - DomainPortEntry* pairs = (DomainPortEntry*)emalloc(sizeof(DomainPortEntry) * max_pairs); + DomainPortEntry* pairs = (DomainPortEntry*)malloc(sizeof(DomainPortEntry) * max_pairs); if (pairs == NULL) { - php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: emalloc error"); - efree(input_dup); + php_error_docref(NULL, E_WARNING, "parse_domain_port_pairs: malloc error"); + free(input_dup); return NULL; } @@ -131,7 +131,7 @@ extern DomainPortEntry * parse_domain_port_pairs(const char* input, int* numPair (*numPairs)++; token = strtok(NULL, delimiter); } - efree(input_dup); + free(input_dup); return pairs; } @@ -214,7 +214,7 @@ extern void tr_client_destroy(TrClient *client) { close(client->socketFd); } if (client->host){ - efree(client->host); + free(client->host); client->host = NULL; } } @@ -278,9 +278,9 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const return -1; } - char *packet = emalloc(MAX_CHUNK_SIZE); + char *packet = malloc(MAX_CHUNK_SIZE); if (!packet) { - php_error_docref(NULL, E_WARNING, "[tr-send_chunks] emalloc failed"); + php_error_docref(NULL, E_WARNING, "[tr-send_chunks] malloc failed"); return -1; } @@ -323,7 +323,7 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const } totalSentSize += sent; } - efree(packet); + free(packet); return totalSentSize; } From 9935a9beee8a3cb50b6b4eed30337ecfbee4cb48 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:31:47 +0300 Subject: [PATCH 38/60] fix: revert tr_network.c to system allocator, fix stubs and instructions --- .opencode/instructions.md | 2 +- tests/stubs/php.h | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.opencode/instructions.md b/.opencode/instructions.md index d269760..3f5c013 100644 --- a/.opencode/instructions.md +++ b/.opencode/instructions.md @@ -33,7 +33,7 @@ This project is a C-based PHP extension for metrics collection. 2. **Compilation:** `make` 3. **PHP tests:** `make test` 4. **C unit tests (network):** - `gcc -I/usr/include/php -I/usr/include/php/main -I/usr/include/php/Zend -I/usr/include/php/TSRM -I/usr/include/php/ext -I. -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test && ./tests/unit_test` + `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test -lm && ./tests/unit_test` 5. **C unit tests (internal — tr_array, tr_timer):** `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/tr_internal_test.c -o tests/tr_internal_test -lm && ./tests/tr_internal_test` *Note:* These use `tests/stubs/php.h` to mock the Zend API outside of PHP context. diff --git a/tests/stubs/php.h b/tests/stubs/php.h index 64dfa1d..0fdd287 100644 --- a/tests/stubs/php.h +++ b/tests/stubs/php.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #define emalloc(s) malloc(s) @@ -15,4 +16,10 @@ typedef unsigned char zend_bool; typedef struct { void *ptr; } zend_resource; +/* Minimal PHP error constants used by tr_network.c */ +#define E_WARNING 2 + +/* No-op stub for php_error_docref used outside PHP context */ +#define php_error_docref(...) + #endif From 68a562ab4dc59ccb30d740d2ccea30b0d6e3248e Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:47:48 +0300 Subject: [PATCH 39/60] refactor: replace tests/stubs/php.h with trochilidae/compat.h - Remove tests/stubs/php.h (standalone stub file) - Add trochilidae/compat.h with #ifdef TROCHILIDAE_STANDALONE for conditional PHP/standalone compilation - Update include in tr_network.c, tr_array.c, tr_timer.h, tr_hooks.h from php.h to trochilidae/compat.h - Add #include to tr_network.c (was implicitly from php.h) - Update CI build.yml: use -DTROCHILIDAE_STANDALONE instead of hardcoded PHP include paths and -Itests/stubs - Update tr_internal_test.c comment --- .github/workflows/build.yml | 4 +- .gitignore | 2 +- tests/stubs/php.h | 25 --------- tests/tr_internal_test.c | 12 ++--- trochilidae/compat.h | 102 ++++++++++++++++++++++++++++++++++++ trochilidae/tr_array.c | 2 +- trochilidae/tr_hooks.h | 2 +- trochilidae/tr_network.c | 3 +- trochilidae/tr_timer.h | 2 +- 9 files changed, 114 insertions(+), 40 deletions(-) delete mode 100644 tests/stubs/php.h create mode 100644 trochilidae/compat.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0892a6d..5065397 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,10 +28,10 @@ jobs: - name: make test run: make test - name: Compile unit tests - run: gcc -I/usr/include/php -I/usr/include/php/main -I/usr/include/php/Zend -I/usr/include/php/TSRM -I/usr/include/php/ext -I. -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test + run: gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test - name: Run unit tests run: ./tests/unit_test - name: Compile internal unit tests (tr_array, tr_timer) - run: gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm + run: gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm - name: Run internal unit tests run: ./tests/tr_internal_test diff --git a/.gitignore b/.gitignore index 0fabec7..9e3f756 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,5 @@ !.opencode !/docs -!/tests/stubs/php.h +!/trochilidae/compat.h !/tests/install_test.sh \ No newline at end of file diff --git a/tests/stubs/php.h b/tests/stubs/php.h deleted file mode 100644 index 0fdd287..0000000 --- a/tests/stubs/php.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef STUB_PHP_H -#define STUB_PHP_H - -#define _GNU_SOURCE -#include -#include -#include -#include -#include - -#define emalloc(s) malloc(s) -#define erealloc(p, s) realloc(p, s) -#define efree(p) free(p) -#define estrdup(s) strdup(s) - -typedef unsigned char zend_bool; -typedef struct { void *ptr; } zend_resource; - -/* Minimal PHP error constants used by tr_network.c */ -#define E_WARNING 2 - -/* No-op stub for php_error_docref used outside PHP context */ -#define php_error_docref(...) - -#endif diff --git a/tests/tr_internal_test.c b/tests/tr_internal_test.c index 01a829e..9d03934 100644 --- a/tests/tr_internal_test.c +++ b/tests/tr_internal_test.c @@ -4,14 +4,10 @@ #include #include -/* This test is compiled WITHOUT real PHP headers. - Instead we use tests/stubs/php.h which provides malloc-based - substitutes for emalloc/erealloc/efree/estrdup and a minimal - zend_resource definition. This lets us unit-test the internal - C code (tr_array, tr_timer) without booting PHP. */ - -/* Include the actual source-under-test. - The stub php.h intercepts #include "php.h" before the real one. */ +/* Standalone unit test — compiled with -DTROCHILIDAE_STANDALONE. + compat.h provides malloc-based stubs for emalloc/efree/estrdup. */ + +/* Include the actual source-under-test. */ #include "trochilidae/tr_array.h" #include "trochilidae/tr_timer.h" diff --git a/trochilidae/compat.h b/trochilidae/compat.h new file mode 100644 index 0000000..4e83cd4 --- /dev/null +++ b/trochilidae/compat.h @@ -0,0 +1,102 @@ +#ifndef TROCHILIDAE_COMPAT_H +#define TROCHILIDAE_COMPAT_H + +#ifdef TROCHILIDAE_STANDALONE + +#include +#include +#include +#include + +#define emalloc(s) malloc(s) +#define erealloc(p, s) realloc(p, s) +#define efree(p) free(p) +#define estrdup(s) strdup(s) + +#define php_error_docref(...) +#define E_WARNING 0 + +#define ZEND_BEGIN_MODULE_GLOBALS(module) +#define ZEND_END_MODULE_GLOBALS(module) +#define ZEND_MODULE_GLOBALS_ACCESSOR(module, v) (module##_globals.v) +#define ZEND_INIT_MODULE_GLOBALS(module, init, copy, dtor) +#define ZEND_DECLARE_MODULE_GLOBALS(module) + +typedef unsigned char zend_bool; +typedef struct { void *ptr; } zend_resource; +typedef struct _zend_function zend_function; +typedef void zval; +typedef struct _zend_execute_data zend_execute_data; + +#define INTERNAL_FUNCTION_PARAMETERS +#define INTERNAL_FUNCTION_PARAM_PASSTHRU +#define Z_OBJ_P(zv) ((void*)(zv)) +#define Z_PTR_P(zv) ((void*)(zv)) +#define ZVAL_STRING(zv, s) +#define RETURN_STRING(s) +#define RETURN_TRUE +#define RETURN_FALSE +#define RETURN_LONG(l) +#define RETURN_DOUBLE(d) +#define RETURN_NULL() +#define RETURN_EMPTY_STRING() +#define RETURN_BOOL(b) +#define RETURN_RESOURCE(r) +#define RETURN_SELF() +#define RETURN_THIS() +#define RETURN_OBJ(o) +#define RETURN_ZVAL(zv, copy, dtor) +#define RETURN_ZVAL_REF(r) +#define RETURN_ARR(arr) +#define RETURN_NEW_OBJ(obj) +#define RETURN_STRINGL(s, l) +#define RETURN_STRINGL_COPY(s, l) +#define RETURN_STRING_COPY(s) +#define RETURN_EMPTY_ARRAY() +#define array_init(zv) +#define add_assoc_string(zv, key, str) +#define add_assoc_stringl(zv, key, str, len) +#define add_assoc_long(zv, key, val) +#define add_assoc_double(zv, key, val) +#define add_assoc_bool(zv, key, val) +#define add_assoc_null(zv, key) +#define add_next_index_string(zv, str) +#define add_next_index_long(zv, val) +#define add_next_index_double(zv, val) +#define add_next_index_bool(zv, val) +#define add_next_index_null(zv) +#define zend_parse_parameters(...) +#define ZEND_NUM_ARGS() 0 +#define ZEND_FUNCTION(name) void name(INTERNAL_FUNCTION_PARAMETERS) +#define ZEND_METHOD(class, name) void class##_##name(INTERNAL_FUNCTION_PARAMETERS) +#define PHP_FUNCTION(name) void name(INTERNAL_FUNCTION_PARAMETERS) +#define PHP_MINIT_FUNCTION(name) int name##_minit(INIT_FUNC_ARGS) +#define PHP_MSHUTDOWN_FUNCTION(name) int name##_mshutdown(INIT_FUNC_ARGS) +#define PHP_RINIT_FUNCTION(name) int name##_rinit(INIT_FUNC_ARGS) +#define PHP_RSHUTDOWN_FUNCTION(name) int name##_rshutdown(INIT_FUNC_ARGS) +#define PHP_MINFO_FUNCTION(name) void name##_minfo(ZEND_MODULE_INFO_FUNC_ARGS) +#define PHP_GINIT_FUNCTION(name) void name##_ginit(void *globals) +#define PHP_GSHUTDOWN_FUNCTION(name) void name##_gshutdown(void *globals) +#define INIT_FUNC_ARGS +#define ZEND_MODULE_INFO_FUNC_ARGS +#define ZEND_MODULE_STARTUP_N(module) (SUCCESS) +#define ZEND_MODULE_SHUTDOWN_N(module) (SUCCESS) +#define ZEND_MODULE_ACTIVATE_N(module) (SUCCESS) +#define ZEND_MODULE_DEACTIVATE_N(module) (SUCCESS) +#define ZEND_MODULE_INFO_N(module) +#define ZEND_GET_MODULE(module) NULL +#define PHP_MINIT(module) ({ 0; }) +#define PHP_MSHUTDOWN(module) ({ 0; }) +#define PHP_RINIT(module) ({ 0; }) +#define PHP_RSHUTDOWN(module) ({ 0; }) +#define PHP_MINFO(module) +#define PHP_GINIT(module) +#define PHP_GSHUTDOWN(module) + +#else + +#include "php.h" + +#endif /* TROCHILIDAE_STANDALONE */ + +#endif /* TROCHILIDAE_COMPAT_H */ diff --git a/trochilidae/tr_array.c b/trochilidae/tr_array.c index 8b5e730..095d2e3 100644 --- a/trochilidae/tr_array.c +++ b/trochilidae/tr_array.c @@ -2,7 +2,7 @@ // Created by mlex on 09.12.2024. // -#include "php.h" +#include "trochilidae/compat.h" #include "tr_array.h" diff --git a/trochilidae/tr_hooks.h b/trochilidae/tr_hooks.h index 9a6e49e..0378166 100644 --- a/trochilidae/tr_hooks.h +++ b/trochilidae/tr_hooks.h @@ -1,7 +1,7 @@ #ifndef PHP_TROCHILIDAE_HOOKS_H #define PHP_TROCHILIDAE_HOOKS_H -#include "php.h" +#include "trochilidae/compat.h" #include "trochilidae/tr_array.h" #define TR_MAX_HOOKS 64 diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index bd3d7d0..ce2e1e8 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -2,7 +2,8 @@ // Created by xMlex on 13.09.2021. // -#include "php.h" +#include "trochilidae/compat.h" +#include #include "trochilidae/tr_network.h" DomainResolveCacheEntry domain_resolve_cache[PHP_TROCHILIDAE_COLLECTORS_MAX]; diff --git a/trochilidae/tr_timer.h b/trochilidae/tr_timer.h index 61954ef..24cbbc2 100644 --- a/trochilidae/tr_timer.h +++ b/trochilidae/tr_timer.h @@ -5,7 +5,7 @@ #ifndef PHP_TROCHILIDAE_TR_TIMER_H #define PHP_TROCHILIDAE_TR_TIMER_H -#include +#include "trochilidae/compat.h" #define Z_TR_TIMER_P(zv) (TrTimer*)Z_OBJ_P((zv)) #define Z_TR_TIMER(zv) (*Z_TR_TIMER_P(zv)) From 96f38a925bcb194bdfc7f2227b0911fd23274cf9 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 10:53:41 +0300 Subject: [PATCH 40/60] add e2e test instructions to .opencode/instructions.md --- .opencode/instructions.md | 44 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/.opencode/instructions.md b/.opencode/instructions.md index 3f5c013..867b084 100644 --- a/.opencode/instructions.md +++ b/.opencode/instructions.md @@ -31,12 +31,54 @@ This project is a C-based PHP extension for metrics collection. ## Build & Test Workflow 1. **Configuration:** `phpize && ./configure --enable-trochilidae` 2. **Compilation:** `make` -3. **PHP tests:** `make test` +3. **PHP .phpt tests:** + `php -d extension=modules/trochilidae.so run-tests.php -d extension=modules/trochilidae.so tests/` + *Note:* The `-d extension=...` flag must be passed **both** to `run-tests.php` (so it propagates to child processes) and to the parent process for the skip checks. 4. **C unit tests (network):** `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test -lm && ./tests/unit_test` 5. **C unit tests (internal — tr_array, tr_timer):** `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/tr_internal_test.c -o tests/tr_internal_test -lm && ./tests/tr_internal_test` *Note:* These use `tests/stubs/php.h` to mock the Zend API outside of PHP context. +### E2E Tests (UDP protocol validation) + +E2E tests verify that the extension correctly sends binary UDP packets (chunked protocol) by directing traffic to a local UDP test server that parses and validates the wire format. + +1. **Start the UDP test server:** + ```bash + php -d extension=modules/trochilidae.so tests/udp_test_server.php --port=30002 --timeout=3 --verbose + ``` + - Listens on `udp://0.0.0.0:30002` for chunked trochilidae packets. + - Reassembles chunks, parses the binary protocol, and prints the decoded fields. + - Exit code `0` — packet received and valid; `1` — timeout/error. + - Supports `--expect=FILE.json` for deterministic field validation. + +2. **Run PHP scripts that send metrics via the extension:** + ```bash + php -d extension=modules/trochilidae.so -d "trochilidae.server_list=127.0.0.1:30002" \ + -r 'trochilidae_set_tag("e2e", "test"); trochilidae_flush();' + ``` + +3. **Full automated E2E cycle** (as used in `tests/install_test.sh`): + ```bash + UDP_PORT=30008 + UDP_LOG=$(mktemp) + php -d extension=modules/trochilidae.so tests/udp_test_server.php \ + --port=$UDP_PORT --timeout=3 --verbose > "$UDP_LOG" 2>&1 & + UDP_PID=$! + # wait for "ready" in log, then run test PHP scripts + php -d extension=modules/trochilidae.so -d "trochilidae.server_list=127.0.0.1:$UDP_PORT" \ + -r 'trochilidae_set_tag("t", "1"); trochilidae_flush();' + sleep 0.5 + kill $UDP_PID 2>/dev/null; wait $UDP_PID 2>/dev/null || true + grep -q "=== Parsed packet ===" "$UDP_LOG" && echo "E2E PASS" || echo "E2E FAIL" + rm -f "$UDP_LOG" + ``` + +**Key points:** +- Tests that run with a real `trochilidae.server_list` setting emit UDP packets — those packets go to the configured server. +- For isolated .phpt tests that don't need a server, set `trochilidae.server_list=localhost` (the packets will be sent but never arrive, which is harmless). +- For full protocol validation, use the UDP test server as shown above. + ## Stubs - `tests/stubs/php.h`: Minimal stub for `emalloc`/`erealloc`/`efree`/`estrdup` and `zend_resource` — used by `tr_internal_test.c` to compile without the real PHP headers. From 8cb92c036158eb6533b745cefb95292114801eee Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 2 Jul 2026 11:07:18 +0300 Subject: [PATCH 41/60] fix build and tests --- .gitignore | 2 +- .opencode/instructions.md => AGENTS.md | 29 ++++++++++++++++++++++++++ tests/004_chunking.phpt | 8 +++++++ 3 files changed, 38 insertions(+), 1 deletion(-) rename .opencode/instructions.md => AGENTS.md (78%) diff --git a/.gitignore b/.gitignore index 9e3f756..9e2eafe 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,8 @@ !/trochilidae !package.xml !Dockerfile -!.opencode !/docs +!AGENTS.md !/trochilidae/compat.h !/tests/install_test.sh \ No newline at end of file diff --git a/.opencode/instructions.md b/AGENTS.md similarity index 78% rename from .opencode/instructions.md rename to AGENTS.md index 867b084..a6388c1 100644 --- a/.opencode/instructions.md +++ b/AGENTS.md @@ -75,10 +75,39 @@ E2E tests verify that the extension correctly sends binary UDP packets (chunked rm -f "$UDP_LOG" ``` +4. **Multi-chunk E2E test** (verifies chunking with small `chunk_size`): + + Use `ini_set()` — setting `chunk_size` before `server_list` is required (see known bug below): + ```bash + UDP_PORT=30009 + UDP_LOG=$(mktemp) + php -d extension=modules/trochilidae.so tests/udp_test_server.php \ + --port=$UDP_PORT --timeout=3 --verbose > "$UDP_LOG" 2>&1 & + UDP_PID=$! + sleep 0.5 + php -d extension=modules/trochilidae.so \ + -r ' + ini_set("trochilidae.chunk_size", 120); + ini_set("trochilidae.server_list", "127.0.0.1:'$UDP_PORT'"); + for ($i = 0; $i < 50; $i++) { + trochilidae_set_tag("k$i", str_repeat("x", 25)); + } + trochilidae_flush(); + ' + sleep 0.8 + kill $UDP_PID 2>/dev/null; wait $UDP_PID 2>/dev/null || true + chunks=$(grep -c "Received chunk" "$UDP_LOG") + echo "Chunks: $chunks" + grep -q "All chunks received" "$UDP_LOG" && echo "MULTI-CHUNK PASS" || echo "FAIL" + rm -f "$UDP_LOG" + ``` + Expected: ~21 chunks (99 bytes payload each with `chunk_size=120`, since `CHUNK_HEADER_SIZE=21`). + **Key points:** - Tests that run with a real `trochilidae.server_list` setting emit UDP packets — those packets go to the configured server. - For isolated .phpt tests that don't need a server, set `trochilidae.server_list=localhost` (the packets will be sent but never arrive, which is harmless). - For full protocol validation, use the UDP test server as shown above. +- **Known bug**: `trochilidae.chunk_size` and `trochilidae.server_list` cannot both be set via `-d` flags — the INI handler for `server_list` fires before `chunk_size` is applied, so collectors always get the default `chunk_size=65507`. Always use `ini_set()` for both when testing small chunk sizes, with `chunk_size` set **before** `server_list`. ## Stubs - `tests/stubs/php.h`: Minimal stub for `emalloc`/`erealloc`/`efree`/`estrdup` and `zend_resource` — used by `tr_internal_test.c` to compile without the real PHP headers. diff --git a/tests/004_chunking.phpt b/tests/004_chunking.phpt index 30dd307..b9d4e62 100644 --- a/tests/004_chunking.phpt +++ b/tests/004_chunking.phpt @@ -4,6 +4,14 @@ Check multi-chunk packet with small chunk_size --FILE-- Date: Mon, 20 Jul 2026 08:28:33 +0300 Subject: [PATCH 42/60] fix alpine build --- trochilidae/utils.h | 1 + 1 file changed, 1 insertion(+) diff --git a/trochilidae/utils.h b/trochilidae/utils.h index ecb2679..784afc5 100644 --- a/trochilidae/utils.h +++ b/trochilidae/utils.h @@ -11,6 +11,7 @@ #include #include #include +#include #define PHP_TROCHILIDAE_COLLECTORS_MAX 10 From f501065458873f7d50ba9c5a4ed9d1e633b57ac4 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:36:03 +0300 Subject: [PATCH 43/60] fix alpine build --- trochilidae/utils.c | 1 + trochilidae/utils.h | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/trochilidae/utils.c b/trochilidae/utils.c index 4cff692..c3d1bcb 100644 --- a/trochilidae/utils.c +++ b/trochilidae/utils.c @@ -4,6 +4,7 @@ #include "trochilidae/utils.h" #include +#include extern void d2tv(double x, struct timeval *tv) { tv->tv_sec = (long) x; diff --git a/trochilidae/utils.h b/trochilidae/utils.h index 784afc5..ecb2679 100644 --- a/trochilidae/utils.h +++ b/trochilidae/utils.h @@ -11,7 +11,6 @@ #include #include #include -#include #define PHP_TROCHILIDAE_COLLECTORS_MAX 10 From 8c33bdaa95a03d9af1e715d8b2152cabe8c72f91 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:39:38 +0300 Subject: [PATCH 44/60] run tests on alpine --- .github/workflows/build.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5065397..1983526 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,3 +35,41 @@ jobs: run: gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm - name: Run internal unit tests run: ./tests/tr_internal_test + + build-alpine: + name: Trochilidae Alpine (PHP ${{ matrix.php-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.2', '8.5'] + + container: + image: php:${{ matrix.php-version }}-fpm-alpine + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install build dependencies + run: apk add --no-cache $PHPIZE_DEPS linux-headers + + - name: phpize + run: phpize + + - name: configure + run: ./configure --enable-trochilidae + + - name: make + run: make -j"$(nproc)" + + - name: make test + run: make test + - name: Compile unit tests + run: gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test + - name: Run unit tests + run: ./tests/unit_test + - name: Compile internal unit tests (tr_array, tr_timer) + run: gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm + - name: Run internal unit tests + run: ./tests/tr_internal_test \ No newline at end of file From 611267c04e880bc969adfee757b8b7dbcd6025aa Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:41:44 +0300 Subject: [PATCH 45/60] [alpine] fix ./trochilidae/tr_timer.h:17:5: error: unknown type name 'uint'; did you mean 'int'? --- trochilidae/tr_timer.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trochilidae/tr_timer.h b/trochilidae/tr_timer.h index 24cbbc2..3fd127d 100644 --- a/trochilidae/tr_timer.h +++ b/trochilidae/tr_timer.h @@ -14,7 +14,7 @@ typedef struct { char *name; int nameLen; struct timeval executionTime, totalExecutionTime; - uint startCount, stopCount; + unsigned int startCount, stopCount; // php zend_resource *resource; } TrTimer; From 6e1d79d5a131c362462a8471ac59287d5baa0c74 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:53:49 +0300 Subject: [PATCH 46/60] fix collector lifecycle and chunk send failure handling Harden collector socket lifecycle to avoid invalid descriptor closes and skip invalid collectors in the send path. Fail chunked sends on EAGAIN/EWOULDBLOCK so dropped chunks are surfaced instead of silently losing request metrics. Co-authored-by: Cursor --- trochilidae.c | 9 ++++++--- trochilidae/tr_network.c | 12 +++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/trochilidae.c b/trochilidae.c index 86c1140..9b629c7 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -337,9 +337,9 @@ static int send_data() { // init clients for (int i = 0; i < collector_count; i++) { - tr_client_refresh_server(&TR_G(collectors)[i]); - - //printf("send_data: %zu to %s:%d\n", sizeMsg, TR_G(collectors)[i].host, TR_G(collectors)[i].port); + if (!TR_G(collectors)[i].initialized || TR_G(collectors)[i].host == NULL) { + continue; + } const ssize_t cnt = tr_client_send(&TR_G(collectors)[i], TR_G(msg).data, sizeMsg); if (cnt == -1) { @@ -362,6 +362,9 @@ static int send_data() { static void php_trochilidae_ctor_globals(zend_trochilidae_globals *globals) { memset(globals, 0, sizeof(*globals)); + for (int i = 0; i < PHP_TROCHILIDAE_COLLECTORS_MAX; ++i) { + globals->collectors[i].socketFd = -1; + } gethostname(globals->hostName, sizeof(globals->hostName)); } diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index ce2e1e8..ce62ed7 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -197,6 +197,8 @@ extern bool tr_client_create(TrClient *client) { fcntl(client->socketFd, F_SETFL, flags | O_NONBLOCK); if (!tr_client_set_addr_info(client)) { + close(client->socketFd); + client->socketFd = -1; return false; } client->initialized = true; @@ -213,6 +215,7 @@ extern void tr_client_destroy(TrClient *client) { client->initialized = false; if (client->socketFd >= 0) { close(client->socketFd); + client->socketFd = -1; } if (client->host){ free(client->host); @@ -262,11 +265,11 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const return -1; } - const size_t chunk_size = client->chunk_size - CHUNK_HEADER_SIZE; - if (chunk_size <= 0) { + if (client->chunk_size <= CHUNK_HEADER_SIZE) { php_error_docref(NULL, E_WARNING, "[tr-send_chunks] chunk_size small, need > %d", CHUNK_HEADER_SIZE); return -1; } + const size_t chunk_size = client->chunk_size - CHUNK_HEADER_SIZE; if (client->chunk_size > MAX_CHUNK_SIZE) { php_error_docref(NULL, E_WARNING, "[tr-send_chunks] Chunk size too large(%zu), max: %d", chunk_size, MAX_CHUNK_SIZE); return -1; @@ -317,9 +320,12 @@ ssize_t send_chunks(TrClient *client, const byte *data, const size_t size, const if (sent < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) { client->drops++; - continue; + php_error_docref(NULL, E_NOTICE, "[tr-send_chunks] dropped chunk due to EAGAIN/EWOULDBLOCK"); + free(packet); + return -1; } php_error_docref(NULL, E_WARNING, "[tr-send_chunks] sendto error: %s", strerror(errno)); + free(packet); return -1; } totalSentSize += sent; From 1ce78dd1e898c27a01b46260595ac2487422c553 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:56:53 +0300 Subject: [PATCH 47/60] fix timestamp serialization and defensive request metrics encoding Correct request_start_time microsecond scaling and guard phpinfo average calculations against zero requests. Make argv serialization defensive for non-string entries to avoid invalid string dereferences. Co-authored-by: Cursor --- trochilidae.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/trochilidae.c b/trochilidae.c index 9b629c7..1b126a2 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -294,7 +294,11 @@ static int send_data() { zval *val; // Итерация по аргументам ZEND_HASH_FOREACH_VAL(Z_ARR_P(argvList), val) { - tr_array_write_string(&TR_G(msg), Z_STRVAL_P(val)); + if (Z_TYPE_P(val) == IS_STRING) { + tr_array_write_string(&TR_G(msg), Z_STRVAL_P(val)); + } else { + tr_array_write_string(&TR_G(msg), NULL); + } } ZEND_HASH_FOREACH_END(); } } else { @@ -381,7 +385,8 @@ static PHP_MINFO_FUNCTION(trochilidae) { snprintf(bufName, sizeof(bufName), "%lu", TR_G(requestCount)); php_info_print_table_row(2, "Requests", bufName); - snprintf(bufName, sizeof(bufName), "%lu AVG: %lu", TR_G(bytesSend), TR_G(bytesSend) / TR_G(requestCount)); + const unsigned long avgBytes = TR_G(requestCount) > 0 ? (TR_G(bytesSend) / TR_G(requestCount)) : 0; + snprintf(bufName, sizeof(bufName), "%lu AVG: %lu", TR_G(bytesSend), avgBytes); php_info_print_table_row(2, "Bytes send", bufName); snprintf(bufName, sizeof(bufName), "%d", *tr_network_get_domain_resolve_cache_size()); @@ -509,13 +514,13 @@ static inline struct timeval tr_fetch_global_var_tv(const char *name) { if (zv && Z_TYPE_P(zv) == IS_DOUBLE) { double time_float = Z_DVAL_P(zv); tv.tv_sec = (time_t) time_float; - tv.tv_usec = (suseconds_t) ((time_float - tv.tv_sec) * 1e6 * 1000); + tv.tv_usec = (suseconds_t) ((time_float - tv.tv_sec) * 1e6); } else if (zv && Z_TYPE_P(zv) == IS_STRING) { char *endptr; double time_float = strtod(Z_STRVAL_P(zv), &endptr); if (*endptr == '\0') { tv.tv_sec = (time_t) time_float; - tv.tv_usec = (suseconds_t) ((time_float - tv.tv_sec) * 1e6 * 1000); + tv.tv_usec = (suseconds_t) ((time_float - tv.tv_sec) * 1e6); } else { php_error_docref(NULL, E_NOTICE, "[tr] incorrect string val in _SERVER[%s] variable", name); } From 9de6f08225752b9f0ab22c946c669ca79e673bf9 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:57:59 +0300 Subject: [PATCH 48/60] harden network host checks and align protocol validation Protect address setup against null hosts, update protocol docs to match the actual string wire format, and validate timeval microseconds in the UDP integration parser to catch serialization regressions. Co-authored-by: Cursor --- docs/protocol.md | 2 +- tests/udp_test_server.php | 10 ++++++++++ trochilidae/tr_network.c | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index ae2e2c0..f870a83 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -38,7 +38,7 @@ All multi-byte integers are **little-endian** (native x86_64 byte order via `mem | `word` | 4 | `uint32_t` | Unsigned 32-bit LE | | `long` | 8 | `uint64_t` | Unsigned 64-bit LE | | `tv` | 8 | `struct timeval` | `word(tv_sec)` + `word(tv_usec)` | -| `string` | 2 + N | `char[]` | `short(length)` + N raw bytes (no NUL terminator sent) | +| `string` | 4 + N | `char[]` | `word(length)` + N raw bytes (no NUL terminator sent) | ## Payload Layout diff --git a/tests/udp_test_server.php b/tests/udp_test_server.php index 7ff65ee..e097eac 100755 --- a/tests/udp_test_server.php +++ b/tests/udp_test_server.php @@ -123,12 +123,16 @@ public function __construct(string $body) { public function parse(): array { $this->result['request_start_time'] = $this->r->readTimeval(); + $this->assertTimevalUsecRange('request_start_time', $this->result['request_start_time']); $this->result['mode_type'] = $this->r->readByte(); $this->result['request_method'] = $this->r->readByte(); $this->result['mem_peak_usage'] = $this->r->readLong(); $this->result['execution_time'] = $this->r->readTimeval(); + $this->assertTimevalUsecRange('execution_time', $this->result['execution_time']); $this->result['cpu_user_time'] = $this->r->readTimeval(); + $this->assertTimevalUsecRange('cpu_user_time', $this->result['cpu_user_time']); $this->result['cpu_system_time'] = $this->r->readTimeval(); + $this->assertTimevalUsecRange('cpu_system_time', $this->result['cpu_system_time']); $this->result['response_http_size'] = $this->r->readLong(); $this->result['response_code'] = $this->r->readWord(); @@ -170,6 +174,12 @@ public function parse(): array { $this->result['_unparsed_bytes'] = $this->r->remaining(); return $this->result; } + + private function assertTimevalUsecRange(string $field, array $timeval): void { + if ($timeval['usec'] < 0 || $timeval['usec'] > 999999) { + throw new \RuntimeException("Invalid timeval usec in {$field}: {$timeval['usec']}"); + } + } } function formatTimeval(array $tv): string { diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index ce62ed7..3377343 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -224,7 +224,7 @@ extern void tr_client_destroy(TrClient *client) { } int tr_client_set_addr_info(TrClient *client) { - if (strcmp(client->host, "") == 0) { + if (client->host == NULL || strcmp(client->host, "") == 0) { php_error_docref(NULL, E_WARNING, "tr_client_set_addr_info: not set client->host"); return false; } From 39223bfd15b3fb81fc8f8662b8313f73b344528a Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 08:58:58 +0300 Subject: [PATCH 49/60] restore patched handlers during module shutdown Detach patched Zend handlers at module shutdown and restore sapi ub_write to the original callback to avoid stale function pointers after extension unload/reload. Co-authored-by: Cursor --- trochilidae.c | 4 ++++ trochilidae/tr_hooks.c | 15 +++++++++++++++ trochilidae/tr_hooks.h | 1 + 3 files changed, 20 insertions(+) diff --git a/trochilidae.c b/trochilidae.c index 1b126a2..956a90b 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -203,6 +203,10 @@ static PHP_MINIT_FUNCTION(trochilidae) { } static PHP_MSHUTDOWN_FUNCTION(trochilidae) { + tr_hooks_detach(); + if (sapi_old_ub_write != NULL) { + sapi_module.ub_write = sapi_old_ub_write; + } for (int i = 0; i < PHP_TROCHILIDAE_COLLECTORS_MAX; ++i) { tr_client_destroy(&TR_G(collectors[i])); } diff --git a/trochilidae/tr_hooks.c b/trochilidae/tr_hooks.c index 32cf370..0c34580 100644 --- a/trochilidae/tr_hooks.c +++ b/trochilidae/tr_hooks.c @@ -124,6 +124,21 @@ void tr_hooks_lazy_attach(void) { efree(dup); } +void tr_hooks_detach(void) { + for (uint32_t i = 0; i < TR_G(hook_count); i++) { + if (!TR_G(hooks)[i].zend_func || !TR_G(hooks)[i].original_handler) { + continue; + } + ((zend_internal_function *)TR_G(hooks)[i].zend_func)->handler = TR_G(hooks)[i].original_handler; + TR_G(hooks)[i].zend_func = NULL; + TR_G(hooks)[i].original_handler = NULL; + TR_G(hooks)[i].call_count = 0; + TR_G(hooks)[i].total_time = (struct timeval){0, 0}; + } + TR_G(hook_count) = 0; + TR_G(hooks_attached) = false; +} + void tr_hooks_reset(void) { for (uint32_t i = 0; i < TR_G(hook_count); i++) { TR_G(hooks)[i].call_count = 0; diff --git a/trochilidae/tr_hooks.h b/trochilidae/tr_hooks.h index 0378166..930f044 100644 --- a/trochilidae/tr_hooks.h +++ b/trochilidae/tr_hooks.h @@ -24,6 +24,7 @@ typedef struct { } TrHookEntry; void tr_hooks_lazy_attach(void); +void tr_hooks_detach(void); void tr_hooks_reset(void); void tr_hooks_serialize(struct tr_array *msg); From 8e06f14f8529378ac1be5d64e0f916dc86b1cf73 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 10:55:00 +0300 Subject: [PATCH 50/60] fix fpm request lifecycle memory handling and add repro coverage Move request-scoped cleanup to RSHUTDOWN, harden reset/flush lifecycle guards, and add Docker-based FPM repro tooling with CI coverage so heap corruption regressions are caught reliably on Alpine. Co-authored-by: Cursor --- .github/workflows/build.yml | 6 +- AGENTS.md | 24 +++++ php_trochilidae.h | 1 + tests/003_stress.phpt | 34 +++++-- tests/fpm_min_request.php | 5 ++ tests/fpm_repro_request.php | 19 ++++ tests/fpm_repro_test.sh | 150 +++++++++++++++++++++++++++++++ tests/run_fpm_repro_in_docker.sh | 12 +++ tests/tr_array_memory.phpt | 23 +++-- trochilidae.c | 99 +++++++++++++------- 10 files changed, 326 insertions(+), 47 deletions(-) create mode 100644 tests/fpm_min_request.php create mode 100644 tests/fpm_repro_request.php create mode 100755 tests/fpm_repro_test.sh create mode 100755 tests/run_fpm_repro_in_docker.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1983526..3744d6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: uses: actions/checkout@v4 - name: Install build dependencies - run: apk add --no-cache $PHPIZE_DEPS linux-headers + run: apk add --no-cache $PHPIZE_DEPS linux-headers bash fcgi ripgrep - name: phpize run: phpize @@ -72,4 +72,6 @@ jobs: - name: Compile internal unit tests (tr_array, tr_timer) run: gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm - name: Run internal unit tests - run: ./tests/tr_internal_test \ No newline at end of file + run: ./tests/tr_internal_test + - name: Run FPM repro test + run: bash tests/fpm_repro_test.sh \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a6388c1..37cb688 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ This project is a C-based PHP extension for metrics collection. - `utils.c/h`: Helper functions and utility macros. - `docs/protocol.md`: Wire format specification for the UDP protocol. - `tests/`: Contains `.phpt` files for integration testing. +- `.github/workflows/build.yml` build and run tests CI ## Core Conventions - **Naming:** @@ -40,6 +41,12 @@ This project is a C-based PHP extension for metrics collection. `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/tr_internal_test.c -o tests/tr_internal_test -lm && ./tests/tr_internal_test` *Note:* These use `tests/stubs/php.h` to mock the Zend API outside of PHP context. +6. **Docker FPM repro test** (recommended for FPM crash validation): + `tests/run_fpm_repro_in_docker.sh` + - Builds extension inside `php:8.2-fpm-bookworm`. + - Runs `.phpt` tests and then `tests/fpm_repro_test.sh` in FPM mode. + - Expected success output includes: `FPM repro test passed`. + ### E2E Tests (UDP protocol validation) E2E tests verify that the extension correctly sends binary UDP packets (chunked protocol) by directing traffic to a local UDP test server that parses and validates the wire format. @@ -109,5 +116,22 @@ E2E tests verify that the extension correctly sends binary UDP packets (chunked - For full protocol validation, use the UDP test server as shown above. - **Known bug**: `trochilidae.chunk_size` and `trochilidae.server_list` cannot both be set via `-d` flags — the INI handler for `server_list` fires before `chunk_size` is applied, so collectors always get the default `chunk_size=65507`. Always use `ini_set()` for both when testing small chunk sizes, with `chunk_size` set **before** `server_list`. +### FPM Repro Scripts (brief) + +- `tests/fpm_repro_test.sh` — launches local php-fpm + UDP server, sends multiple FastCGI requests, fails on crash signatures (`zend_mm_heap corrupted`, `SIGABRT`, `SIGSEGV`) or UDP absence. +- `tests/fpm_repro_request.php` — request script with tag/timer activity and `$_SERVER` mutations (main FPM stress scenario). +- `tests/fpm_min_request.php` — minimal control request script (`echo` only). + +Quick usage: + +```bash +# full run (build + phpt + fpm repro) in Docker +tests/run_fpm_repro_in_docker.sh + +# run only FPM repro in container with minimal request script +docker run --rm -e REQUEST_SCRIPT=/work/tests/fpm_min_request.php \ + trochilidae-fpm-repro:local bash -lc '/work/tests/fpm_repro_test.sh' +``` + ## Stubs - `tests/stubs/php.h`: Minimal stub for `emalloc`/`erealloc`/`efree`/`estrdup` and `zend_resource` — used by `tr_internal_test.c` to compile without the real PHP headers. diff --git a/php_trochilidae.h b/php_trochilidae.h index 09eb5cf..9f582fd 100644 --- a/php_trochilidae.h +++ b/php_trochilidae.h @@ -24,6 +24,7 @@ ZEND_BEGIN_MODULE_GLOBALS(trochilidae) bool enabled; bool flashed; + bool in_send_data; bool modeCli; zend_long chunk_size; char *server_list; diff --git a/tests/003_stress.phpt b/tests/003_stress.phpt index 3237b1e..dcf98b2 100644 --- a/tests/003_stress.phpt +++ b/tests/003_stress.phpt @@ -1,23 +1,39 @@ --TEST-- -Check hostname setting and reset/flush functionality +Check reset/flush stress and server fallback paths --SKIPIF-- +--INI-- +trochilidae.server_list=localhost --FILE-- --EXPECT-- -Hostname set successfully +stress start OK diff --git a/tests/fpm_min_request.php b/tests/fpm_min_request.php new file mode 100644 index 0000000..79836c5 --- /dev/null +++ b/tests/fpm_min_request.php @@ -0,0 +1,5 @@ +/dev/null || true + fi + if [ -n "${UDP_PID:-}" ]; then + kill "$UDP_PID" 2>/dev/null || true + fi + wait "${UDP_PID:-0}" 2>/dev/null || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cat > "$FPM_CONF" < "$PHP_INI_FILE" < "$POOL_CONF" < "$UDP_LOG" 2>&1 & +UDP_PID=$! + +for _ in $(seq 1 40); do + if rg -q "^ready$" "$UDP_LOG"; then + break + fi + sleep 0.1 +done + +"$PHP_FPM_BIN" -c "$PHP_INI_FILE" -y "$FPM_CONF" + +for _ in $(seq 1 40); do + if [ -f "$FPM_PID" ]; then + break + fi + sleep 0.1 +done + +if [ ! -f "$FPM_PID" ]; then + echo "FAIL: php-fpm did not start" + rg "." "$FPM_LOG" || true + exit 1 +fi + +for i in $(seq 1 "$REQUESTS"); do + if ! QUERY_STRING="i=$i" \ + REQUEST_METHOD=GET \ + SCRIPT_FILENAME="$REQUEST_SCRIPT" \ + SCRIPT_NAME="/fpm_repro_request.php" \ + REQUEST_URI="/fpm_repro_request.php?i=$i" \ + SERVER_PROTOCOL=HTTP/1.1 \ + SERVER_NAME=localhost \ + HTTP_HOST=localhost \ + GATEWAY_INTERFACE=CGI/1.1 \ + timeout "$FCGI_TIMEOUT_SEC" "$FCGI_BIN" -bind -connect "$FPM_SOCK" > /dev/null; then + echo "FAIL: cgi-fcgi request timed out or failed at iteration $i" + rg "." "$FPM_LOG" || true + exit 1 + fi +done + +sleep 0.5 + +if rg -q "zend_mm_heap corrupted|exited on signal 6|SIGABRT|core dumped" "$FPM_LOG"; then + echo "FAIL: FPM crash signature found" + rg "zend_mm_heap corrupted|exited on signal 6|SIGABRT|core dumped" "$FPM_LOG" || true + exit 1 +fi + +if ! rg -q "=== Parsed packet ===" "$UDP_LOG"; then + echo "FAIL: no UDP packet was parsed" + rg "." "$UDP_LOG" || true + exit 1 +fi + +echo "FPM repro test passed" diff --git a/tests/run_fpm_repro_in_docker.sh b/tests/run_fpm_repro_in_docker.sh new file mode 100755 index 0000000..e700914 --- /dev/null +++ b/tests/run_fpm_repro_in_docker.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_TAG="${IMAGE_TAG:-trochilidae-fpm-repro:local}" +DOCKERFILE_PATH="$ROOT_DIR/tests/docker-fpm-repro.Dockerfile" + +echo "Building Docker image: $IMAGE_TAG" +docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_TAG" "$ROOT_DIR" + +echo "Running PHPT + FPM repro tests in container" +docker run --rm --name "trochilidae-fpm-repro-run" "$IMAGE_TAG" diff --git a/tests/tr_array_memory.phpt b/tests/tr_array_memory.phpt index d87a8f8..8cea5bf 100644 --- a/tests/tr_array_memory.phpt +++ b/tests/tr_array_memory.phpt @@ -1,13 +1,24 @@ --TEST-- -Check tr_array functionality with new memory allocation +Check tr_array growth and repeated flush/reset cycles --SKIPIF-- +--INI-- +trochilidae.server_list=localhost --FILE-- --EXPECT-- -Extension loaded successfully. +array stress ok diff --git a/trochilidae.c b/trochilidae.c index 956a90b..b8f9f5c 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -21,6 +21,8 @@ size_t (*sapi_old_ub_write)(const char *str, size_t str_length); TrTimer *get_or_create_tr_timer(zend_string *timerName); void update_server_list(); +static void tr_cleanup_request_data_strings(void); +static char *tr_dup_server_var(const char *name); #ifdef COMPILE_DL_TROCHILIDAE ZEND_GET_MODULE(trochilidae) @@ -221,15 +223,32 @@ static PHP_RINIT_FUNCTION(trochilidae) { } static PHP_RSHUTDOWN_FUNCTION(trochilidae) { - tr_flush(); + tr_flush(); + tr_cleanup_request_data_strings(); + if (Z_TYPE(TR_G(tags)) == IS_ARRAY) { + zval_dtor(&TR_G(tags)); + ZVAL_UNDEF(&TR_G(tags)); + } + if (Z_TYPE(TR_G(timers)) == IS_ARRAY) { + zval_dtor(&TR_G(timers)); + ZVAL_UNDEF(&TR_G(timers)); + } return SUCCESS; } static int tr_reset() { + if (TR_G(in_send_data)) { + return FAILURE; + } + TR_G(flashed) = false; collect_metrics_before_request(); - zval_dtor(&TR_G(tags)); - zval_dtor(&TR_G(timers)); + if (Z_TYPE(TR_G(tags)) == IS_ARRAY) { + zval_dtor(&TR_G(tags)); + } + if (Z_TYPE(TR_G(timers)) == IS_ARRAY) { + zval_dtor(&TR_G(timers)); + } array_init(&TR_G(tags)); array_init(&TR_G(timers)); return SUCCESS; @@ -246,6 +265,11 @@ static int tr_flush() { } static int send_data() { + if (TR_G(in_send_data)) { + return FAILURE; + } + TR_G(in_send_data) = true; + collect_metrics_after_request(); uint8_t modeType = PHP_TROCHILIDAE_MODE_CGI; @@ -253,22 +277,14 @@ static int send_data() { modeType = PHP_TROCHILIDAE_MODE_CLI; } - bool domain_fallback = false; - char *request_domain; - if (TR_G(requestData).request_domain) { - request_domain = TR_G(requestData).request_domain; - } else { - request_domain = strdup(sapi_module.name); - domain_fallback = true; + const char *request_domain = TR_G(requestData).request_domain; + if (request_domain == NULL) { + request_domain = sapi_module.name; } - bool uri_fallback = false; - char *request_uri; - if (TR_G(requestData).request_uri) { - request_uri = TR_G(requestData).request_uri; - } else { - request_uri = strdup(sapi_module.name); - uri_fallback = true; + const char *request_uri = TR_G(requestData).request_uri; + if (request_uri == NULL) { + request_uri = sapi_module.name; } tr_array_init(&TR_G(msg), 0); @@ -361,8 +377,7 @@ static int send_data() { tr_array_free(&TR_G(msg)); - if (domain_fallback) free(request_domain); - if (uri_fallback) free(request_uri); + TR_G(in_send_data) = false; return SUCCESS; @@ -377,6 +392,12 @@ static void php_trochilidae_ctor_globals(zend_trochilidae_globals *globals) { } static void php_trochilidae_dtor_globals(zend_trochilidae_globals *globals) { + globals->requestData.request_id = NULL; + globals->requestData.request_uri = NULL; + globals->requestData.request_domain = NULL; + if (globals->msg.data) { + tr_array_free(&globals->msg); + } } static PHP_MINFO_FUNCTION(trochilidae) { @@ -439,22 +460,17 @@ static void collect_metrics_before_request() { tv_assign(&TR_G(requestData).CPUUsageUserTime, &u.ru_utime); tv_assign(&TR_G(requestData).CPUUsageSystemTime, &u.ru_stime); - if (TR_G(requestData).request_id) { - efree(TR_G(requestData).request_id); - TR_G(requestData).request_id = NULL; - } - TR_G(requestData).response_http_size = 0; if (TR_G(modeCli)) { TR_G(requestData).request_method = PHP_TROCHILIDAE_REQUEST_METHOD_NONE; - TR_G(requestData).request_uri = tr_fetch_global_var("SCRIPT_FILENAME"); - TR_G(requestData).request_domain = tr_fetch_global_var("PWD"); + TR_G(requestData).request_uri = tr_dup_server_var("SCRIPT_FILENAME"); + TR_G(requestData).request_domain = tr_dup_server_var("PWD"); } else { TR_G(requestData).request_method = tr_request_method_map(tr_fetch_global_var("REQUEST_METHOD")); - TR_G(requestData).request_uri = tr_fetch_global_var("REQUEST_URI"); - TR_G(requestData).request_domain = tr_fetch_global_var("HTTP_HOST"); + TR_G(requestData).request_uri = tr_dup_server_var("REQUEST_URI"); + TR_G(requestData).request_domain = tr_dup_server_var("HTTP_HOST"); if (TR_G(requestData).request_domain == NULL) { - TR_G(requestData).request_domain = tr_fetch_global_var("SERVER_NAME"); + TR_G(requestData).request_domain = tr_dup_server_var("SERVER_NAME"); } } TR_G(requestData).request_start_time = tr_fetch_global_var_tv("REQUEST_TIME_FLOAT"); @@ -490,12 +506,35 @@ static size_t sapi_ub_write_counter(const char *str, size_t length) { static inline char *tr_fetch_global_var(const char *name) { zval *tmp = tr_fetch_global_var_zval(name); - if (tmp) { + if (tmp && Z_TYPE_P(tmp) == IS_STRING) { return Z_STRVAL_P(tmp); } return NULL; } +static void tr_cleanup_request_data_strings(void) { + if (TR_G(requestData).request_id) { + efree(TR_G(requestData).request_id); + TR_G(requestData).request_id = NULL; + } + if (TR_G(requestData).request_uri) { + efree(TR_G(requestData).request_uri); + TR_G(requestData).request_uri = NULL; + } + if (TR_G(requestData).request_domain) { + efree(TR_G(requestData).request_domain); + TR_G(requestData).request_domain = NULL; + } +} + +static char *tr_dup_server_var(const char *name) { + const char *value = tr_fetch_global_var(name); + if (value == NULL) { + return NULL; + } + return estrdup(value); +} + static inline zval *tr_fetch_global_var_ar(const char *name) { zval *tmp = tr_fetch_global_var_zval(name); if (!tmp) { From 3035f10ca213b7c8a886a11a1261b2a283f727c3 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 11:17:38 +0300 Subject: [PATCH 51/60] add alpine-based cli/fpm stress and debug test harness Expand repro coverage with high-volume CLI+FPM request simulation, add valgrind/gdb debug runners, and align debug container tooling with Alpine 8.5.8 to mirror production behavior. Co-authored-by: Cursor --- tests/cli_repro_request.php | 20 ++++++++ tests/docker-fpm-debug.Dockerfile | 23 ++++++++++ tests/fpm_repro_test.sh | 76 +++++++++++++++++++++++++++---- tests/run_cli_debug.sh | 29 ++++++++++++ tests/run_fpm_debug.sh | 70 ++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 tests/cli_repro_request.php create mode 100644 tests/docker-fpm-debug.Dockerfile create mode 100755 tests/run_cli_debug.sh create mode 100755 tests/run_fpm_debug.sh diff --git a/tests/cli_repro_request.php b/tests/cli_repro_request.php new file mode 100644 index 0000000..8071ae7 --- /dev/null +++ b/tests/cli_repro_request.php @@ -0,0 +1,20 @@ +/dev/null || true @@ -58,10 +84,38 @@ cleanup() { kill "$UDP_PID" 2>/dev/null || true fi wait "${UDP_PID:-0}" 2>/dev/null || true - rm -rf "$TMP_DIR" + if [ "$KEEP_TMP_DIR" != "1" ]; then + rm -rf "$TMP_DIR" + else + echo "TMP_DIR=$TMP_DIR" + fi } trap cleanup EXIT +check_fpm_log_for_crash() { + if rg -q "$CRASH_PATTERN" "$FPM_LOG"; then + echo "FAIL: FPM crash signature found" + rg "$CRASH_PATTERN" "$FPM_LOG" || true + exit 1 + fi +} + +run_cli_requests() { + for i in $(seq 1 "$CLI_REQUESTS"); do + if ! TROCHILIDAE_REQ_INDEX="$i" \ + timeout "$CLI_TIMEOUT_SEC" \ + "${PHP_BIN_WRAPPER_ARGS[@]}" "$PHP_BIN" "${PHP_BIN_ARGS[@]}" \ + -d "extension=$SO_PATH" \ + -d "trochilidae.enabled=$TROCHILIDAE_ENABLED" \ + -d "trochilidae.server_list=127.0.0.1:$UDP_PORT" \ + "$CLI_REQUEST_SCRIPT" "$i" >> "$CLI_LOG" 2>&1; then + echo "FAIL: CLI request failed at iteration $i" + rg "." "$CLI_LOG" || true + exit 1 + fi + done +} + cat > "$FPM_CONF" < "$UDP_LOG" 2>&1 & +"${PHP_BIN_WRAPPER_ARGS[@]}" "$PHP_BIN" "${PHP_BIN_ARGS[@]}" -d "extension=$SO_PATH" "$ROOT_DIR/tests/udp_test_server.php" --port="$UDP_PORT" --timeout=8 --verbose > "$UDP_LOG" 2>&1 & UDP_PID=$! for _ in $(seq 1 40); do @@ -101,7 +155,9 @@ for _ in $(seq 1 40); do sleep 0.1 done -"$PHP_FPM_BIN" -c "$PHP_INI_FILE" -y "$FPM_CONF" +run_cli_requests + +"${PHP_FPM_WRAPPER_ARGS[@]}" "$PHP_FPM_BIN" "${PHP_FPM_ARGS[@]}" -c "$PHP_INI_FILE" -y "$FPM_CONF" for _ in $(seq 1 40); do if [ -f "$FPM_PID" ]; then @@ -131,15 +187,19 @@ for i in $(seq 1 "$REQUESTS"); do rg "." "$FPM_LOG" || true exit 1 fi + if [ $((i % FPM_HEALTH_CHECK_EVERY)) -eq 0 ]; then + check_fpm_log_for_crash + if ! kill -0 "$(cat "$FPM_PID")" 2>/dev/null; then + echo "FAIL: php-fpm master is not running at iteration $i" + rg "." "$FPM_LOG" || true + exit 1 + fi + fi done sleep 0.5 -if rg -q "zend_mm_heap corrupted|exited on signal 6|SIGABRT|core dumped" "$FPM_LOG"; then - echo "FAIL: FPM crash signature found" - rg "zend_mm_heap corrupted|exited on signal 6|SIGABRT|core dumped" "$FPM_LOG" || true - exit 1 -fi +check_fpm_log_for_crash if ! rg -q "=== Parsed packet ===" "$UDP_LOG"; then echo "FAIL: no UDP packet was parsed" diff --git a/tests/run_cli_debug.sh b/tests/run_cli_debug.sh new file mode 100755 index 0000000..e11ba2a --- /dev/null +++ b/tests/run_cli_debug.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SO_PATH="${SO_PATH:-$ROOT_DIR/modules/trochilidae.so}" +VALGRIND_LOG="${VALGRIND_LOG:-$ROOT_DIR/tests/valgrind-cli.log}" + +if [ ! -f "$SO_PATH" ]; then + echo "FAIL: extension not found at $SO_PATH" + exit 1 +fi + +echo "Running CLI under valgrind..." +valgrind --tool=memcheck \ + --leak-check=full \ + --track-origins=yes \ + --num-callers=40 \ + --error-limit=no \ + --log-file="$VALGRIND_LOG" \ + php -d "extension=$SO_PATH" -r ' + trochilidae_set_tag("debug", "cli"); + trochilidae_timer_start("cli_debug"); + trochilidae_timer_stop("cli_debug"); + trochilidae_flush(); + echo "CLI debug probe done\n"; + ' + +echo "Valgrind CLI log: $VALGRIND_LOG" +rg "Invalid read|Invalid write|Use of uninitialised|trochilidae|ERROR SUMMARY" "$VALGRIND_LOG" || true diff --git a/tests/run_fpm_debug.sh b/tests/run_fpm_debug.sh new file mode 100755 index 0000000..dfad5f3 --- /dev/null +++ b/tests/run_fpm_debug.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_TAG="${IMAGE_TAG:-trochilidae-fpm-debug:local}" +DOCKERFILE_PATH="$ROOT_DIR/tests/docker-fpm-debug.Dockerfile" + +if [ "${1:-}" = "--inside-container" ]; then + ulimit -c unlimited || true + + set +e + KEEP_TMP_DIR=1 REQUESTS="${REQUESTS:-20}" FCGI_TIMEOUT_SEC="${FCGI_TIMEOUT_SEC:-5}" \ + /work/tests/fpm_repro_test.sh + status=$? + set -e + + if [ "$status" -eq 0 ]; then + echo "Debug run passed (no crash detected in baseline run)." + exit 0 + fi + + echo "Baseline run failed. Re-running under valgrind for detailed trace..." + VALGRIND_FPM_LOG="/tmp/valgrind-fpm.log" + VALGRIND_CLI_LOG="/tmp/valgrind-cli.log" + + set +e + KEEP_TMP_DIR=1 REQUESTS=1 FCGI_TIMEOUT_SEC=15 \ + PHP_BIN_WRAPPER="valgrind --tool=memcheck --track-origins=yes --num-callers=40 --error-limit=no --log-file=$VALGRIND_CLI_LOG" \ + PHP_FPM_WRAPPER="valgrind --tool=memcheck --track-origins=yes --num-callers=40 --error-limit=no --log-file=$VALGRIND_FPM_LOG" \ + /work/tests/fpm_repro_test.sh + valgrind_status=$? + set -e + + echo "Valgrind logs:" + echo " CLI: $VALGRIND_CLI_LOG" + echo " FPM: $VALGRIND_FPM_LOG" + if [ -f "$VALGRIND_CLI_LOG" ]; then + rg "Invalid read|Invalid write|Use of uninitialised|free\\(|trochilidae|ERROR SUMMARY" "$VALGRIND_CLI_LOG" || true + fi + if [ -f "$VALGRIND_FPM_LOG" ]; then + rg "Invalid read|Invalid write|Use of uninitialised|free\\(|trochilidae|ERROR SUMMARY" "$VALGRIND_FPM_LOG" || true + fi + + echo "Attempting to print core backtrace if available..." + for candidate in /tmp/core* /work/core*; do + if [ -f "$candidate" ]; then + echo "Core file found: $candidate" + gdb -q -batch \ + -ex "set pagination off" \ + -ex "thread apply all bt full" \ + "$(command -v php-fpm)" "$candidate" || true + break + fi + done + + if [ "$valgrind_status" -ne 0 ]; then + exit "$valgrind_status" + fi + exit "$status" +fi + +echo "Building debug Docker image: $IMAGE_TAG" +docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_TAG" "$ROOT_DIR" + +echo "Running valgrind/gdb FPM debug session in container" +docker run --rm \ + --cap-add=SYS_PTRACE \ + --security-opt seccomp=unconfined \ + --name "trochilidae-fpm-debug-run" \ + "$IMAGE_TAG" From 0c7952add4bd86a2d121a02632c00b50e7322d53 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 11:40:34 +0300 Subject: [PATCH 52/60] add problematic send metric to phpinfo output Track failed tr_client_send attempts in module globals and expose total and per-request average in phpinfo for faster runtime diagnostics. Co-authored-by: Cursor --- php_trochilidae.h | 1 + trochilidae.c | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/php_trochilidae.h b/php_trochilidae.h index 9f582fd..df5e683 100644 --- a/php_trochilidae.h +++ b/php_trochilidae.h @@ -40,6 +40,7 @@ ZEND_BEGIN_MODULE_GLOBALS(trochilidae) zval tags; zval timers; unsigned long bytesSend; + unsigned long problematicSends; unsigned long requestCount; ZEND_END_MODULE_GLOBALS(trochilidae) diff --git a/trochilidae.c b/trochilidae.c index b8f9f5c..7549abf 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -367,6 +367,7 @@ static int send_data() { const ssize_t cnt = tr_client_send(&TR_G(collectors)[i], TR_G(msg).data, sizeMsg); if (cnt == -1) { + TR_G(problematicSends)++; char *errorBuf = strerror(errno); php_error_docref(NULL, E_NOTICE, "[trochilidae] tr_net_send: %zu - %s, address: %s:%i", @@ -424,6 +425,12 @@ static PHP_MINFO_FUNCTION(trochilidae) { snprintf(bufName, sizeof(bufName), "%lu", totalDrops); php_info_print_table_row(2, "Dropped packets", bufName); + const unsigned long avgProblematic = TR_G(requestCount) > 0 + ? (TR_G(problematicSends) / TR_G(requestCount)) + : 0; + snprintf(bufName, sizeof(bufName), "%lu AVG: %lu", TR_G(problematicSends), avgProblematic); + php_info_print_table_row(2, "Problematic sends", bufName); + for (int i = 0; i < collector_count; ++i) { if (!TR_G(collectors)[i].initialized) { continue; From 5243cd15f804a2752e80d80d4bee9089f5922878 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 13:55:10 +0300 Subject: [PATCH 53/60] [alpine] fix hooks --- tests/udp_test_server.php | 15 +++++++++++++++ trochilidae.c | 2 +- trochilidae/tr_hooks.c | 26 +++++++++++++++++++++----- trochilidae/tr_network.c | 10 +++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/tests/udp_test_server.php b/tests/udp_test_server.php index e097eac..197d23b 100755 --- a/tests/udp_test_server.php +++ b/tests/udp_test_server.php @@ -171,6 +171,20 @@ public function parse(): array { ]; } + // hooks + $hookCount = $this->r->readShort(); + $this->result['hooks'] = []; + for ($i = 0; $i < $hookCount; $i++) { + $name = $this->r->readString(); + $callCount = $this->r->readLong(); + $totalTime = $this->r->readTimeval(); + $this->assertTimevalUsecRange("hooks[$name].total_time", $totalTime); + $this->result['hooks'][$name] = [ + 'call_count' => $callCount, + 'total_time' => $totalTime, + ]; + } + $this->result['_unparsed_bytes'] = $this->r->remaining(); return $this->result; } @@ -221,6 +235,7 @@ function printResult(array $result, int $totalBytes): void { echo "argv (" . count($result['argv']) . "): " . (empty($result['argv']) ? '(none)' : implode(', ', $result['argv'])) . "\n"; echo "tags (" . count($result['tags']) . "): " . (empty($result['tags']) ? '(none)' : json_encode($result['tags'])) . "\n"; echo "timers (" . count($result['timers']) . "): " . (empty($result['timers']) ? '(none)' : json_encode($result['timers'])) . "\n"; + echo "hooks (" . count($result['hooks']) . "): " . (empty($result['hooks']) ? '(none)' : json_encode($result['hooks'])) . "\n"; echo "total bytes: $totalBytes (unparsed: {$result['_unparsed_bytes']})\n"; echo "========================\n"; } diff --git a/trochilidae.c b/trochilidae.c index 7549abf..9390159 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -184,7 +184,7 @@ PHP_INI_BEGIN() trochilidae_globals) STD_PHP_INI_ENTRY("trochilidae.server_list", NULL, PHP_INI_ALL, onUpdateServerList, server_list, zend_trochilidae_globals, trochilidae_globals) - STD_PHP_INI_ENTRY("trochilidae.hook_list", "", PHP_INI_ALL, OnUpdateString, hook_list, + STD_PHP_INI_ENTRY("trochilidae.hook_list", "SoapClient->__soapCall,curl_exec,curl_multi_exec,file_get_contents,file_put_contents", PHP_INI_ALL, OnUpdateString, hook_list, zend_trochilidae_globals, trochilidae_globals) STD_PHP_INI_BOOLEAN("trochilidae.debug", "0", PHP_INI_ALL, OnUpdateBool, debug, zend_trochilidae_globals, trochilidae_globals) diff --git a/trochilidae/tr_hooks.c b/trochilidae/tr_hooks.c index 0c34580..189b9e2 100644 --- a/trochilidae/tr_hooks.c +++ b/trochilidae/tr_hooks.c @@ -32,6 +32,21 @@ static void trim_tail(char *p) { p[len] = '\0'; } +static zval *tr_hash_str_find_ci(HashTable *ht, const char *name) { + size_t len = strlen(name); + zval *found = zend_hash_str_find(ht, name, len); + if (found != NULL) { + return found; + } + + char *lower_name = estrndup(name, len); + zend_str_tolower(lower_name, len); + found = zend_hash_str_find(ht, lower_name, len); + efree(lower_name); + + return found; +} + void tr_hooks_lazy_attach(void) { if (TR_G(hooks_attached)) return; TR_G(hooks_attached) = true; @@ -65,6 +80,7 @@ void tr_hooks_lazy_attach(void) { char *method = sep + (sep[1] == '>' ? 2 : (sep[1] == ':' ? 2 : 1)); trim_tail(p); method = (char *)skip_spaces(method); + trim_tail(method); if (*p && *method) { e->type = TR_HOOK_METHOD; strlcpy(e->class_name, p, sizeof(e->class_name)); @@ -85,13 +101,13 @@ void tr_hooks_lazy_attach(void) { zend_function *fe = NULL; if (e->type == TR_HOOK_FUNCTION) { - zval *tmp = zend_hash_str_find(EG(function_table), e->func_name, strlen(e->func_name)); + zval *tmp = tr_hash_str_find_ci(EG(function_table), e->func_name); if (tmp) fe = Z_PTR_P(tmp); } else { - zval *tmp = zend_hash_str_find(CG(class_table), e->class_name, strlen(e->class_name)); + zval *tmp = tr_hash_str_find_ci(CG(class_table), e->class_name); if (tmp) { zend_class_entry *ce = Z_PTR_P(tmp); - zval *m = zend_hash_str_find(&ce->function_table, e->func_name, strlen(e->func_name)); + zval *m = tr_hash_str_find_ci(&ce->function_table, e->func_name); if (m) fe = Z_PTR_P(m); } } @@ -157,12 +173,12 @@ static void build_hook_name(TrHookEntry *e, char *buf, size_t bufsz) { void tr_hooks_serialize(struct tr_array *msg) { uint32_t count = 0; for (uint32_t i = 0; i < TR_G(hook_count); i++) { - if (TR_G(hooks)[i].zend_func) count++; + if (TR_G(hooks)[i].zend_func && TR_G(hooks)[i].call_count > 0) count++; } tr_array_write_short(msg, &count); for (uint32_t i = 0; i < TR_G(hook_count); i++) { - if (!TR_G(hooks)[i].zend_func) continue; + if (!TR_G(hooks)[i].zend_func || TR_G(hooks)[i].call_count == 0) continue; char name[512]; build_hook_name(&TR_G(hooks)[i], name, sizeof(name)); tr_array_write_string(msg, name); diff --git a/trochilidae/tr_network.c b/trochilidae/tr_network.c index 3377343..3ef836e 100644 --- a/trochilidae/tr_network.c +++ b/trochilidae/tr_network.c @@ -194,7 +194,15 @@ extern bool tr_client_create(TrClient *client) { } int flags = fcntl(client->socketFd, F_GETFL, 0); - fcntl(client->socketFd, F_SETFL, flags | O_NONBLOCK); + fcntl(client->socketFd, F_SETFL, flags & ~O_NONBLOCK); + + struct timeval timeout; + timeout.tv_sec = 0; + timeout.tv_usec = 100000; + + if (setsockopt(client->socketFd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)) < 0) { + php_error_docref(NULL, E_WARNING, "[tr_client_create] setsockopt SO_SNDTIMEO failed"); + } if (!tr_client_set_addr_info(client)) { close(client->socketFd); From cf0d32817eb2f0037aae8b060b4e70cc70016105 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Mon, 20 Jul 2026 13:59:31 +0300 Subject: [PATCH 54/60] [alpine] fix rm: can't remove '/tmp/tmp.AFKNdC/php-fpm.pid': No such file or directory --- tests/fpm_repro_test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fpm_repro_test.sh b/tests/fpm_repro_test.sh index a725000..2193288 100755 --- a/tests/fpm_repro_test.sh +++ b/tests/fpm_repro_test.sh @@ -85,7 +85,7 @@ cleanup() { fi wait "${UDP_PID:-0}" 2>/dev/null || true if [ "$KEEP_TMP_DIR" != "1" ]; then - rm -rf "$TMP_DIR" + rm -rf "$TMP_DIR" || true else echo "TMP_DIR=$TMP_DIR" fi From 17c1d5062b2ea3854f5cc8e674f3eee22c829484 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Fri, 14 Aug 2026 10:42:04 +0300 Subject: [PATCH 55/60] Fix chunk_size INI ordering and document build/test workflow. Register onUpdateChunkSize so collectors pick up chunk_size when set via -d after server_list; refresh AGENTS.md and chunking test notes; add hooks integration test. --- AGENTS.md | 18 +++---- tests/004_chunking.phpt | 2 - tests/005_hooks.phpt | 109 ++++++++++++++++++++++++++++++++++++++++ trochilidae.c | 33 +++++++++--- 4 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 tests/005_hooks.phpt diff --git a/AGENTS.md b/AGENTS.md index 37cb688..8f24304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ This project is a C-based PHP extension for metrics collection. - **`trochilidae/tr_array.c`**, **`trochilidae/tr_timer.c`**, and **`trochilidae/tr_hooks.c`** use the **Zend allocator** (`emalloc`/`efree`/`estrdup`). - **`trochilidae.c`** uses **both**: data crossing the boundary with `tr_network.c` (`client->host`, `pairs`) uses system allocator to match; PHP-internal data (`request_id`) uses Zend allocator. - **Callers must match the callee's allocator**: if `parse_domain_port_pairs` allocates with `malloc`, the caller must free with `free` (not `efree`). Never mix allocators for the same pointer. -- **Test stubs** (`tests/stubs/php.h`) map `emalloc → malloc`, `efree → free`, `estrdup → strdup` to allow internal test builds outside of PHP context. +- **Test stubs** (`trochilidae/compat.h`): compile with `-DTROCHILIDAE_STANDALONE` to map `emalloc → malloc`, `efree → free`, `estrdup → strdup` for unit tests outside PHP. ## Build & Test Workflow 1. **Configuration:** `phpize && ./configure --enable-trochilidae` @@ -36,14 +36,14 @@ This project is a C-based PHP extension for metrics collection. `php -d extension=modules/trochilidae.so run-tests.php -d extension=modules/trochilidae.so tests/` *Note:* The `-d extension=...` flag must be passed **both** to `run-tests.php` (so it propagates to child processes) and to the parent process for the skip checks. 4. **C unit tests (network):** - `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test -lm && ./tests/unit_test` + `gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -D_GNU_SOURCE -g -O0 tests/unit_test.c trochilidae/tr_network.c trochilidae/utils.c -o tests/unit_test -lm && ./tests/unit_test` 5. **C unit tests (internal — tr_array, tr_timer):** - `gcc -I. -Itests/stubs -DHAVE_CONFIG_H -g -O0 tests/tr_internal_test.c -o tests/tr_internal_test -lm && ./tests/tr_internal_test` - *Note:* These use `tests/stubs/php.h` to mock the Zend API outside of PHP context. + `gcc -I. -DTROCHILIDAE_STANDALONE -DHAVE_CONFIG_H -g -O0 -Wall tests/tr_internal_test.c -o tests/tr_internal_test -lm && ./tests/tr_internal_test` + *Note:* Requires `config.h` from `./configure`. Uses `trochilidae/compat.h` with `-DTROCHILIDAE_STANDALONE` instead of real PHP headers. 6. **Docker FPM repro test** (recommended for FPM crash validation): `tests/run_fpm_repro_in_docker.sh` - - Builds extension inside `php:8.2-fpm-bookworm`. + - Builds extension inside `php:8.5-fpm-alpine`. - Runs `.phpt` tests and then `tests/fpm_repro_test.sh` in FPM mode. - Expected success output includes: `FPM repro test passed`. @@ -84,7 +84,6 @@ E2E tests verify that the extension correctly sends binary UDP packets (chunked 4. **Multi-chunk E2E test** (verifies chunking with small `chunk_size`): - Use `ini_set()` — setting `chunk_size` before `server_list` is required (see known bug below): ```bash UDP_PORT=30009 UDP_LOG=$(mktemp) @@ -93,9 +92,9 @@ E2E tests verify that the extension correctly sends binary UDP packets (chunked UDP_PID=$! sleep 0.5 php -d extension=modules/trochilidae.so \ + -d trochilidae.chunk_size=120 \ + -d "trochilidae.server_list=127.0.0.1:$UDP_PORT" \ -r ' - ini_set("trochilidae.chunk_size", 120); - ini_set("trochilidae.server_list", "127.0.0.1:'$UDP_PORT'"); for ($i = 0; $i < 50; $i++) { trochilidae_set_tag("k$i", str_repeat("x", 25)); } @@ -114,7 +113,6 @@ E2E tests verify that the extension correctly sends binary UDP packets (chunked - Tests that run with a real `trochilidae.server_list` setting emit UDP packets — those packets go to the configured server. - For isolated .phpt tests that don't need a server, set `trochilidae.server_list=localhost` (the packets will be sent but never arrive, which is harmless). - For full protocol validation, use the UDP test server as shown above. -- **Known bug**: `trochilidae.chunk_size` and `trochilidae.server_list` cannot both be set via `-d` flags — the INI handler for `server_list` fires before `chunk_size` is applied, so collectors always get the default `chunk_size=65507`. Always use `ini_set()` for both when testing small chunk sizes, with `chunk_size` set **before** `server_list`. ### FPM Repro Scripts (brief) @@ -134,4 +132,4 @@ docker run --rm -e REQUEST_SCRIPT=/work/tests/fpm_min_request.php \ ``` ## Stubs -- `tests/stubs/php.h`: Minimal stub for `emalloc`/`erealloc`/`efree`/`estrdup` and `zend_resource` — used by `tr_internal_test.c` to compile without the real PHP headers. +- `trochilidae/compat.h`: Standalone stubs for `emalloc`/`erealloc`/`efree`/`estrdup` and minimal Zend types — used by `tr_internal_test.c` and `unit_test.c` when compiled with `-DTROCHILIDAE_STANDALONE`. diff --git a/tests/004_chunking.phpt b/tests/004_chunking.phpt index b9d4e62..4605608 100644 --- a/tests/004_chunking.phpt +++ b/tests/004_chunking.phpt @@ -10,8 +10,6 @@ Check multi-chunk packet with small chunk_size // ini_set("trochilidae.server_list", "127.0.0.1:30009"); // 3. Check logs for "Received chunk X/21" and "All chunks received" // -// Known bug: chunk_size and server_list must BOTH be set via ini_set(), -// NOT via -d flags. Setting chunk_size BEFORE server_list is required. ini_set("trochilidae.chunk_size", 120); ini_set("trochilidae.server_list", "127.0.0.1"); diff --git a/tests/005_hooks.phpt b/tests/005_hooks.phpt new file mode 100644 index 0000000..1fb6177 --- /dev/null +++ b/tests/005_hooks.phpt @@ -0,0 +1,109 @@ +--TEST-- +Check hook capture includes called methods only +--SKIPIF-- + +--INI-- +trochilidae.hook_list=date,curl_exec,DateTimeImmutable->format,DateTimeImmutable::format +--FILE-- + ['pipe', 'r'], + 1 => ['file', $logFile, 'a'], + 2 => ['file', $logFile, 'a'], +]; + +$proc = proc_open($cmd, $descriptors, $pipes, __DIR__); +if (!is_resource($proc)) { + unlink($logFile); + die("failed to start udp_test_server\n"); +} + +fclose($pipes[0]); + +$ready = false; +for ($i = 0; $i < 40; $i++) { + $log = (string)@file_get_contents($logFile); + if (strpos($log, "ready\n") !== false) { + $ready = true; + break; + } + usleep(100000); +} + +if (!$ready) { + proc_terminate($proc); + proc_close($proc); + $log = (string)@file_get_contents($logFile); + unlink($logFile); + die("udp_test_server is not ready\n" . $log); +} + +ini_set("trochilidae.server_list", "127.0.0.1:$port"); + +$date = new DateTimeImmutable(); +$date->format("Y-m-d H:i:s"); +date("c"); +trochilidae_flush(); + +$parsed = false; +for ($i = 0; $i < 40; $i++) { + $log = (string)@file_get_contents($logFile); + if (strpos($log, "=== Parsed packet ===") !== false) { + $parsed = true; + break; + } + usleep(100000); +} + +proc_terminate($proc); +proc_close($proc); + +$log = (string)@file_get_contents($logFile); +unlink($logFile); + +if (!$parsed) { + die("packet was not parsed\n" . $log); +} + +if (!preg_match('/^hooks \(\d+\):\s+(.+)$/m', $log, $m)) { + die("hooks line not found\n" . $log); +} + +$hooks = json_decode($m[1], true); +if (!is_array($hooks)) { + die("failed to decode hooks json: " . $m[1] . "\n"); +} + +$hookNames = array_keys($hooks); +sort($hookNames); + +$expected = ['DateTimeImmutable->format', 'date']; +sort($expected); + +if ($hookNames !== $expected) { + die("unexpected hooks: " . json_encode($hookNames) . "\n" . $log); +} + +if (($hooks['date']['call_count'] ?? 0) < 1) { + die("date call_count is invalid\n"); +} +if (($hooks['DateTimeImmutable->format']['call_count'] ?? 0) < 1) { + die("DateTimeImmutable->format call_count is invalid\n"); +} + +if (isset($hooks['curl_exec']) || isset($hooks['DateTimeImmutable::format'])) { + die("unexpected non-called hook present\n"); +} + +echo "OK\n"; +?> +--EXPECT-- +OK diff --git a/trochilidae.c b/trochilidae.c index 9390159..659c60f 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -81,6 +81,20 @@ TrTimer *get_or_create_tr_timer(zend_string *timerName) { return timer; } +static size_t tr_effective_chunk_size(void) { + if (TR_G(chunk_size) < CHUNK_HEADER_SIZE + 1 || (unsigned long)TR_G(chunk_size) > MAX_CHUNK_SIZE) { + return MAX_CHUNK_SIZE; + } + return (size_t)TR_G(chunk_size); +} + +static void tr_apply_chunk_size_to_collectors(void) { + size_t cs = tr_effective_chunk_size(); + for (int i = 0; i < collector_count; i++) { + TR_G(collectors)[i].chunk_size = cs; + } +} + void update_server_list() { int prev_count = collector_count; DomainPortEntry *pairs = parse_domain_port_pairs(TR_G(server_list), &collector_count); @@ -109,11 +123,7 @@ void update_server_list() { free(TR_G(collectors)[i].host); TR_G(collectors)[i].host = NULL; } - if (TR_G(chunk_size) < CHUNK_HEADER_SIZE + 1 || (unsigned long)TR_G(chunk_size) > MAX_CHUNK_SIZE) { - TR_G(collectors)[i].chunk_size = MAX_CHUNK_SIZE; - } else { - TR_G(collectors)[i].chunk_size = (size_t)TR_G(chunk_size); - } + TR_G(collectors)[i].chunk_size = tr_effective_chunk_size(); } free(pairs); @@ -170,6 +180,15 @@ static const zend_function_entry functions[] = { PHP_FE_END }; +ZEND_INI_MH(onUpdateChunkSize) { + if (!new_value) { + return FAILURE; + } + TR_G(chunk_size) = zend_atol(new_value->val, new_value->len); + tr_apply_chunk_size_to_collectors(); + return SUCCESS; +} + ZEND_INI_MH(onUpdateServerList) { if (!new_value) { return FAILURE; @@ -182,14 +201,14 @@ ZEND_INI_MH(onUpdateServerList) { PHP_INI_BEGIN() STD_PHP_INI_BOOLEAN("trochilidae.enabled", "1", PHP_INI_ALL, OnUpdateBool, enabled, zend_trochilidae_globals, trochilidae_globals) + STD_PHP_INI_ENTRY("trochilidae.chunk_size", "65507", PHP_INI_ALL, onUpdateChunkSize, chunk_size, + zend_trochilidae_globals, trochilidae_globals) STD_PHP_INI_ENTRY("trochilidae.server_list", NULL, PHP_INI_ALL, onUpdateServerList, server_list, zend_trochilidae_globals, trochilidae_globals) STD_PHP_INI_ENTRY("trochilidae.hook_list", "SoapClient->__soapCall,curl_exec,curl_multi_exec,file_get_contents,file_put_contents", PHP_INI_ALL, OnUpdateString, hook_list, zend_trochilidae_globals, trochilidae_globals) STD_PHP_INI_BOOLEAN("trochilidae.debug", "0", PHP_INI_ALL, OnUpdateBool, debug, zend_trochilidae_globals, trochilidae_globals) - STD_PHP_INI_ENTRY("trochilidae.chunk_size", "65507", PHP_INI_ALL, OnUpdateLong, chunk_size, - zend_trochilidae_globals, trochilidae_globals) PHP_INI_END() static PHP_MINIT_FUNCTION(trochilidae) { From 6887dd214d829716012995643d69ccb61bed79e1 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Fri, 14 Aug 2026 11:00:20 +0300 Subject: [PATCH 56/60] Fix hooks test on PHP 7.4 where json is not loaded by run-tests. Parse hook metrics from the UDP log with regex instead of json_decode, and include stdbool.h for the arc4random fallback build path. --- tests/005_hooks.phpt | 31 +++++++++++-------------------- trochilidae/utils.c | 1 + 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/005_hooks.phpt b/tests/005_hooks.phpt index 1fb6177..31441b7 100644 --- a/tests/005_hooks.phpt +++ b/tests/005_hooks.phpt @@ -73,34 +73,25 @@ if (!$parsed) { die("packet was not parsed\n" . $log); } -if (!preg_match('/^hooks \(\d+\):\s+(.+)$/m', $log, $m)) { +if (!preg_match('/^hooks \((\d+)\):\s+(.+)$/m', $log, $m)) { die("hooks line not found\n" . $log); } -$hooks = json_decode($m[1], true); -if (!is_array($hooks)) { - die("failed to decode hooks json: " . $m[1] . "\n"); -} - -$hookNames = array_keys($hooks); -sort($hookNames); +$hookCount = (int) $m[1]; +$hooksLine = trim($m[2]); -$expected = ['DateTimeImmutable->format', 'date']; -sort($expected); - -if ($hookNames !== $expected) { - die("unexpected hooks: " . json_encode($hookNames) . "\n" . $log); +if ($hookCount !== 2 || $hooksLine === '(none)') { + die("unexpected hook count: $hookCount\n" . $log); } -if (($hooks['date']['call_count'] ?? 0) < 1) { - die("date call_count is invalid\n"); +if (!preg_match('/"date"\s*:\s*\{"call_count"\s*:\s*([1-9]\d*)/', $hooksLine)) { + die("date hook missing or zero calls\n" . $log); } -if (($hooks['DateTimeImmutable->format']['call_count'] ?? 0) < 1) { - die("DateTimeImmutable->format call_count is invalid\n"); +if (!preg_match('/"DateTimeImmutable->format"\s*:\s*\{"call_count"\s*:\s*([1-9]\d*)/', $hooksLine)) { + die("DateTimeImmutable->format hook missing or zero calls\n" . $log); } - -if (isset($hooks['curl_exec']) || isset($hooks['DateTimeImmutable::format'])) { - die("unexpected non-called hook present\n"); +if (preg_match('/"curl_exec"/', $hooksLine) || preg_match('/"DateTimeImmutable::format"/', $hooksLine)) { + die("unexpected non-called hook present\n" . $log); } echo "OK\n"; diff --git a/trochilidae/utils.c b/trochilidae/utils.c index c3d1bcb..368dcbd 100644 --- a/trochilidae/utils.c +++ b/trochilidae/utils.c @@ -4,6 +4,7 @@ #include "trochilidae/utils.h" #include +#include #include extern void d2tv(double x, struct timeval *tv) { From 5fcd8a29f5fe7e453786dfab34b72b5285001ecd Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 17 Sep 2026 13:44:50 +0300 Subject: [PATCH 57/60] Fix request data string leak on repeated trochilidae_reset --- trochilidae.c | 1 + 1 file changed, 1 insertion(+) diff --git a/trochilidae.c b/trochilidae.c index 659c60f..4b4421d 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -479,6 +479,7 @@ zend_module_entry trochilidae_module_entry = { }; static void collect_metrics_before_request() { +давай коммит tr_cleanup_request_data_strings(); TR_G(requestCount)++; struct rusage u; gettimeofday(&TR_G(requestData).executionTime, NULL); From 4e04945dc5006294452596fc2f425b14a6541697 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Thu, 17 Sep 2026 13:49:43 +0300 Subject: [PATCH 58/60] fix --- trochilidae.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trochilidae.c b/trochilidae.c index 4b4421d..4bde266 100644 --- a/trochilidae.c +++ b/trochilidae.c @@ -479,7 +479,7 @@ zend_module_entry trochilidae_module_entry = { }; static void collect_metrics_before_request() { -давай коммит tr_cleanup_request_data_strings(); + tr_cleanup_request_data_strings(); TR_G(requestCount)++; struct rusage u; gettimeofday(&TR_G(requestData).executionTime, NULL); From ec97ffd61768ef849d81d43aa3b40470258e5bf6 Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Fri, 18 Sep 2026 11:50:29 +0300 Subject: [PATCH 59/60] composer for pie --- .gitignore | 1 + composer.json | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 composer.json diff --git a/.gitignore b/.gitignore index 9e2eafe..116c9d8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ !Dockerfile !/docs !AGENTS.md +!composer.json !/trochilidae/compat.h !/tests/install_test.sh \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7ed050e --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "xmlex/php_trochilidae", + "type": "php-ext", + "license": "PHP-3.01", + "description": "A PHP extension for metrics collect.", + "require": { + "php": ">=7.4.0 || >=8.0.0" + }, + "php-ext": { + "extension-name": "trochilidae", + "configure-options": [ + { + "name": "with-trochilidae", + "description": "trochilidae prefix path (autodetected if not given)", + "needs-value": false + } + ] + } +} \ No newline at end of file From d112142331de5cad3fab69d69a438b3222a2e1ab Mon Sep 17 00:00:00 2001 From: "Maxim Novikov (xMlex)" Date: Fri, 18 Sep 2026 11:50:46 +0300 Subject: [PATCH 60/60] composer for pie --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7ed050e..450ee61 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "xmlex/php_trochilidae", "type": "php-ext", - "license": "PHP-3.01", + "license": "GPL-3.0 license", "description": "A PHP extension for metrics collect.", "require": { "php": ">=7.4.0 || >=8.0.0"