diff --git a/.cocoon-plan.md b/.cocoon-plan.md index 7f63744..ad166f1 100644 --- a/.cocoon-plan.md +++ b/.cocoon-plan.md @@ -58,9 +58,9 @@ > **注意**:用户已指示恢复 cocoon 开发,继续推进新功能。不暂停。 -- 所有 Phase 1-4 核心功能已完成(反向代理负载均衡 + Prometheus 指标 + 虚拟主机已加入) +- 所有 Phase 1-4 核心功能已完成(反向代理负载均衡 + Prometheus 指标 + 虚拟主机 + 主动健康检查已加入) - 编译通过,零警告 -- **142 个单元测试全部通过** + **106 项集成测试全部通过**(新增 3 项虚拟主机测试) +- **159 个单元测试全部通过**(新增 17 项 healthcheck 单元测试) + **106 项集成测试全部通过**(新增 3 项虚拟主机测试) - 当前仅做编译和测试验证,不添加新代码 ## 待办池(活跃) @@ -69,10 +69,22 @@ - 后端发送 Connection: close 时正确关闭连接而非归还 3. **HTTP/2 代理流式转发** — 当前为非流式(先收集完整响应),未来改为流式 4. ~~虚拟主机 / 多站点~~ ✅ 2026-06-09 — 基于 Host 头匹配,HTTP/1.1 + HTTP/2 均支持 -5. 主动健康检查 — 后台线程定期探测后端(当前为被动式) +5. ~~主动健康检查~~ ✅ 2026-06-09 — 后台线程定期探测后端,4 项集成测试 + 17 项单元测试覆盖 ## 最近行动记录 +- 2026-06-09 18:00: **主动健康检查单元测试** + - 新增 `tests/unit/test_healthcheck.c`:17 项单元测试 + - `healthcheck_parse_status`:覆盖 200/404/500/100/302/503 正常解析、长度不足、非 HTTP 协议、非法状态码 + - `healthcheck_build_request`:覆盖默认路径 `/health`、自定义路径 `/ready`、空路径回退 + - `healthcheck_update_state`:覆盖健康阈值恢复(连续 2 次成功)、不健康阈值标记(连续 3 次失败)、计数器重置、单次失败/成功不触发状态变更、`last_check` 时间戳更新 + - `healthcheck_probe_once`:NULL 后端返回 false + - 使用 `#include "healthcheck.c"` 白盒测试技巧访问静态函数 + - `Makefile`:新增 `test_healthcheck` 编译规则(链接 proxy.c、http.c、platform.c 等依赖) + - 编译零警告,159 个单元测试 + 106 项集成测试全部通过 + - `.cocoon-plan.md`:标记主动健康检查完成 + - 提交已推送到 `main` + - 2026-06-09 15:00: **虚拟主机 / 多站点支持** - `cocoon.h`: 新增 `cocoon_vhost_t` 结构体(`server_name[256]` + `root_dir[512]`),`cocoon_config_t` 新增 `vhosts[COCOON_MAX_VHOSTS]` 和 `num_vhosts` - `config.c`: JSON 解析器新增 `vhosts` 数组解析,每个对象支持 `server_name` 和 `root_dir` 字段 diff --git a/Makefile b/Makefile index 6a8dbe5..a3841b3 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,10 @@ $(UNIT_TEST_DIR)/test_websocket: $(UNIT_TEST_DIR)/test_websocket.c websocket.c l $(UNIT_TEST_DIR)/test_log: $(UNIT_TEST_DIR)/test_log.c log.c $(UNITY_SRC) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_log.c log.c $(UNITY_SRC) -lm +# 单元测试编译规则 +$(UNIT_TEST_DIR)/test_healthcheck: $(UNIT_TEST_DIR)/test_healthcheck.c proxy.c proxy_tls.c http.c log.c platform.c $(UNITY_SRC) + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_healthcheck.c proxy.c proxy_tls.c http.c log.c platform.c $(UNITY_SRC) $(LDFLAGS) + $(UNIT_TEST_DIR)/test_config: $(UNIT_TEST_DIR)/test_config.c config.c log.c access_log.c http.c $(UNITY_SRC) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_config.c config.c log.c access_log.c http.c $(UNITY_SRC) -lm diff --git a/tests/unit/test_healthcheck b/tests/unit/test_healthcheck new file mode 100755 index 0000000..efdaa30 Binary files /dev/null and b/tests/unit/test_healthcheck differ diff --git a/tests/unit/test_healthcheck.c b/tests/unit/test_healthcheck.c new file mode 100644 index 0000000..b73e94b --- /dev/null +++ b/tests/unit/test_healthcheck.c @@ -0,0 +1,240 @@ +#include "unity.h" +#include "healthcheck.h" +#include "proxy.h" +#include +#include +#include +#include +#include +#include +#include + +/* 白盒测试:包含 healthcheck.c 以访问静态函数 */ +#define UNIT_TEST +#include "healthcheck.c" +#undef UNIT_TEST + +/* Unity 测试框架要求的 setUp/tearDown */ +void setUp(void) { } +void tearDown(void) { } + +/* ===== healthcheck_parse_status ===== */ + +void test_parse_status_200(void) { + const char *resp = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + int status = healthcheck_parse_status(resp, strlen(resp)); + TEST_ASSERT_EQUAL(200, status); +} + +void test_parse_status_404(void) { + const char *resp = "HTTP/1.1 404 Not Found\r\n\r\n"; + int status = healthcheck_parse_status(resp, strlen(resp)); + TEST_ASSERT_EQUAL(404, status); +} + +void test_parse_status_500(void) { + const char *resp = "HTTP/1.0 500 Internal Server Error\r\n\r\n"; + int status = healthcheck_parse_status(resp, strlen(resp)); + TEST_ASSERT_EQUAL(500, status); +} + +void test_parse_status_too_short(void) { + TEST_ASSERT_EQUAL(-1, healthcheck_parse_status("HTTP/1.1", 8)); + TEST_ASSERT_EQUAL(-1, healthcheck_parse_status("", 0)); + TEST_ASSERT_EQUAL(-1, healthcheck_parse_status("HTTP", 4)); +} + +void test_parse_status_not_http(void) { + const char *resp = "SSH-2.0-OpenSSH_8.0\r\n"; + TEST_ASSERT_EQUAL(-1, healthcheck_parse_status(resp, strlen(resp))); +} + +void test_parse_status_malformed_status(void) { + const char *resp = "HTTP/1.1 abc OK\r\n\r\n"; + TEST_ASSERT_EQUAL(-1, healthcheck_parse_status(resp, strlen(resp))); +} + +void test_parse_status_edge_cases(void) { + /* 100 Continue */ + const char *resp = "HTTP/1.1 100 Continue\r\n\r\n"; + TEST_ASSERT_EQUAL(100, healthcheck_parse_status(resp, strlen(resp))); + + /* 302 Found */ + const char *resp2 = "HTTP/1.1 302 Found\r\n\r\n"; + TEST_ASSERT_EQUAL(302, healthcheck_parse_status(resp2, strlen(resp2))); + + /* 503 Service Unavailable */ + const char *resp3 = "HTTP/1.1 503 Service Unavailable\r\n\r\n"; + TEST_ASSERT_EQUAL(503, healthcheck_parse_status(resp3, strlen(resp3))); +} + +/* ===== healthcheck_build_request ===== */ + +void test_build_request_default_path(void) { + cocoon_proxy_backend_t backend = {0}; + strncpy(backend.target_host, "localhost", sizeof(backend.target_host) - 1); + backend.target_port = 3000; + + char buf[512]; + healthcheck_build_request(&backend, NULL, buf, sizeof(buf)); + + TEST_ASSERT_TRUE(strstr(buf, "GET /health HTTP/1.1") != NULL); + TEST_ASSERT_TRUE(strstr(buf, "Host: localhost:3000") != NULL); + TEST_ASSERT_TRUE(strstr(buf, "Connection: close") != NULL); + TEST_ASSERT_TRUE(strstr(buf, "Cocoon-Healthcheck/1.0") != NULL); +} + +void test_build_request_custom_path(void) { + cocoon_proxy_backend_t backend = {0}; + strncpy(backend.target_host, "api.example.com", sizeof(backend.target_host) - 1); + backend.target_port = 8080; + + char buf[512]; + healthcheck_build_request(&backend, "/ready", buf, sizeof(buf)); + + TEST_ASSERT_TRUE(strstr(buf, "GET /ready HTTP/1.1") != NULL); + TEST_ASSERT_TRUE(strstr(buf, "Host: api.example.com:8080") != NULL); +} + +void test_build_request_empty_path_uses_default(void) { + cocoon_proxy_backend_t backend = {0}; + strncpy(backend.target_host, "127.0.0.1", sizeof(backend.target_host) - 1); + backend.target_port = 5000; + + char buf[512]; + healthcheck_build_request(&backend, "", buf, sizeof(buf)); + + TEST_ASSERT_TRUE(strstr(buf, "GET /health HTTP/1.1") != NULL); +} + +/* ===== healthcheck_update_state ===== */ + +void test_update_state_healthy_threshold(void) { + cocoon_proxy_backend_t backend = {0}; + backend.healthy = false; + + /* 第1次成功 — 仍不健康 */ + healthcheck_update_state(&backend, true); + TEST_ASSERT_FALSE(backend.healthy); + TEST_ASSERT_EQUAL(1, backend.success_count); + + /* 第2次成功 — 达到阈值,恢复健康 */ + healthcheck_update_state(&backend, true); + TEST_ASSERT_TRUE(backend.healthy); + TEST_ASSERT_EQUAL(2, backend.success_count); + TEST_ASSERT_EQUAL(0, backend.fail_count); +} + +void test_update_state_unhealthy_threshold(void) { + cocoon_proxy_backend_t backend = {0}; + backend.healthy = true; + + /* 连续3次失败 — 达到阈值,标记不健康 */ + healthcheck_update_state(&backend, false); + TEST_ASSERT_TRUE(backend.healthy); /* 第1次,仍健康 */ + TEST_ASSERT_EQUAL(1, backend.fail_count); + + healthcheck_update_state(&backend, false); + TEST_ASSERT_TRUE(backend.healthy); /* 第2次,仍健康 */ + TEST_ASSERT_EQUAL(2, backend.fail_count); + + healthcheck_update_state(&backend, false); + TEST_ASSERT_FALSE(backend.healthy); /* 第3次,标记不健康 */ + TEST_ASSERT_EQUAL(3, backend.fail_count); + TEST_ASSERT_EQUAL(0, backend.success_count); +} + +void test_update_state_reset_counts(void) { + cocoon_proxy_backend_t backend = {0}; + backend.healthy = true; + + /* 先失败1次 */ + healthcheck_update_state(&backend, false); + TEST_ASSERT_EQUAL(1, backend.fail_count); + + /* 然后成功1次 — fail_count 重置 */ + healthcheck_update_state(&backend, true); + TEST_ASSERT_TRUE(backend.healthy); + TEST_ASSERT_EQUAL(0, backend.fail_count); + TEST_ASSERT_EQUAL(1, backend.success_count); + + /* 再失败1次 — success_count 重置 */ + healthcheck_update_state(&backend, false); + TEST_ASSERT_TRUE(backend.healthy); /* 只失败1次,仍健康 */ + TEST_ASSERT_EQUAL(1, backend.fail_count); + TEST_ASSERT_EQUAL(0, backend.success_count); +} + +void test_update_state_stays_healthy_after_single_failure(void) { + cocoon_proxy_backend_t backend = {0}; + backend.healthy = true; + + /* 只失败1次,不应标记不健康 */ + healthcheck_update_state(&backend, false); + TEST_ASSERT_TRUE(backend.healthy); + TEST_ASSERT_EQUAL(1, backend.fail_count); + + /* 再次成功 */ + healthcheck_update_state(&backend, true); + TEST_ASSERT_TRUE(backend.healthy); +} + +void test_update_state_stays_unhealthy_after_single_success(void) { + cocoon_proxy_backend_t backend = {0}; + backend.healthy = false; + + /* 只成功1次,不应标记健康 */ + healthcheck_update_state(&backend, true); + TEST_ASSERT_FALSE(backend.healthy); + TEST_ASSERT_EQUAL(1, backend.success_count); +} + +void test_update_state_last_check_updated(void) { + cocoon_proxy_backend_t backend = {0}; + time_t before = time(NULL); + + healthcheck_update_state(&backend, true); + + time_t after = time(NULL); + TEST_ASSERT_TRUE(backend.last_check >= before); + TEST_ASSERT_TRUE(backend.last_check <= after); +} + +/* ===== healthcheck_probe_once ===== */ + +void test_probe_once_null_backend(void) { + TEST_ASSERT_FALSE(healthcheck_probe_once(NULL, 1000)); +} + +/* ===== 主函数 ===== */ + +int main(void) { + UNITY_BEGIN(); + + /* parse_status */ + RUN_TEST(test_parse_status_200); + RUN_TEST(test_parse_status_404); + RUN_TEST(test_parse_status_500); + RUN_TEST(test_parse_status_too_short); + RUN_TEST(test_parse_status_not_http); + RUN_TEST(test_parse_status_malformed_status); + RUN_TEST(test_parse_status_edge_cases); + + /* build_request */ + RUN_TEST(test_build_request_default_path); + RUN_TEST(test_build_request_custom_path); + RUN_TEST(test_build_request_empty_path_uses_default); + + /* update_state */ + RUN_TEST(test_update_state_healthy_threshold); + RUN_TEST(test_update_state_unhealthy_threshold); + RUN_TEST(test_update_state_reset_counts); + RUN_TEST(test_update_state_stays_healthy_after_single_failure); + RUN_TEST(test_update_state_stays_unhealthy_after_single_success); + RUN_TEST(test_update_state_last_check_updated); + + /* probe_once */ + RUN_TEST(test_probe_once_null_backend); + + return UNITY_END(); +}