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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] =?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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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--