From 0054646784b8b47cdaac0078b322f2fe12b035e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=A7=84=28Lee=20Yunjin=29?= <168180007+gg582@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:34:47 +0900 Subject: [PATCH 1/3] feat(port): serve a CWIST app over lwIP's raw TCP API (#447 Phase 3) (#452) * feat(port): serve a CWIST app over lwIP's raw TCP API (#447 Phase 3) - include/cwist/sys/port/lwip.h + src/sys/port/lwip.c: a listener on a raw-API tcp_pcb (NO_SYS-safe). Requests are framed by Content-Length with the parser's CL/TE rules (501 for Transfer-Encoding, 400 for CL+TE or a bad/conflicting CL, 431/413 above the configured caps), answered with 100 Continue on Expect, parsed and run through cwist_app_dispatch, and serialized. Keep-alive follows request and response as on the socket path; pipelined requests are answered in order. Responses go out zero-copy via tcp_write and are freed once acknowledged; while one is in flight, excess input is refused back to lwIP so the window throttles the peer. Connection cap, idle timeout via tcp_poll, HEAD without body, SOF_REUSEADDR when SO_REUSE is on. - The adapter is compiled by the firmware next to its own lwIP and lwipopts.h; it uses only public CWIST API and is not part of libcwist. - lib/lwip: lwIP STABLE-2_2_1_RELEASE, vendored for the tests only. - tests/test_lwip_adapter.c (make test): real lwIP TCP over the loopback netif in one process, covering keep-alive, pipelining, split bodies, 100-continue, every framing error, Connection: close, HTTP/1.0, HEAD, a 20 KB response through a 2-segment send window, the connection cap, the idle timeout and teardown. Clean under ASan/UBSan/LSan. - tests/baremetal/hello_lwip.c joins make baremetal-smoke: the same stack on Cortex-M7 under qemu-arm. * fix(port): keep cppcheck off the lwIP option #error cppcheck analyzes src/ without the firmware's lwIP tree, so LWIP_TCP was undefined and the requirement check fired. lwip/opt.h always defines LWIP_TCP and LWIP_CALLBACK_API, so check only once it has been seen; a real build with LWIP_TCP=0 still hits the #error. (cherry picked from commit ad7ecad9ed32b332306a3b75e2051ebfb67d43bd) --- .gitignore | 1 + .gitmodules | 3 + Makefile | 31 +- docs/api/v4.0-api-baseline.txt | 20 +- include/cwist/sys/port/lwip.h | 99 ++++++ lib/lwip | 1 + src/sys/port/lwip.c | 548 +++++++++++++++++++++++++++++++++ tests/baremetal/hello_lwip.c | 106 +++++++ tests/lwip/arch/cc.h | 22 ++ tests/lwip/loop_client.c | 170 ++++++++++ tests/lwip/loop_client.h | 44 +++ tests/lwip/lwipopts.h | 54 ++++ tests/test_lwip_adapter.c | 247 +++++++++++++++ 13 files changed, 1341 insertions(+), 5 deletions(-) create mode 100644 include/cwist/sys/port/lwip.h create mode 160000 lib/lwip create mode 100644 src/sys/port/lwip.c create mode 100644 tests/baremetal/hello_lwip.c create mode 100644 tests/lwip/arch/cc.h create mode 100644 tests/lwip/loop_client.c create mode 100644 tests/lwip/loop_client.h create mode 100644 tests/lwip/lwipopts.h create mode 100644 tests/test_lwip_adapter.c diff --git a/.gitignore b/.gitignore index 504264751..7d6a57b19 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ bindings/rust/Cargo.lock # Test binaries test_sstring test_baremetal_port +test_lwip_adapter test_http test_http_client test_siphash diff --git a/.gitmodules b/.gitmodules index 4588e5926..dd31cc27b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -32,3 +32,6 @@ [submodule "lib/mbedtls"] path = lib/mbedtls url = https://github.com/Mbed-TLS/mbedtls +[submodule "lib/lwip"] + path = lib/lwip + url = https://github.com/lwip-tcpip/lwip diff --git a/Makefile b/Makefile index 56a0d9ed8..f3903c91f 100644 --- a/Makefile +++ b/Makefile @@ -536,6 +536,13 @@ wasip2-smoke: $(WASIP2_ARCHIVE) clean-wasip2: rm -rf .wasip2-build libcwist_wasip2_*.a wasip2_smoke.wasm wasm_abi_layout.wasm +# --- lwIP (vendored for the adapter tests, issue #447 Phase 3) --------------------- +# The core and IPv4 sources; tests/lwip/lwipopts.h turns off what the adapter +# tests do not use, so the extra files compile to nothing. +LWIP_DIR = lib/lwip +LWIP_CORE_SRCS = $(wildcard $(LWIP_DIR)/src/core/*.c $(LWIP_DIR)/src/core/ipv4/*.c) +LWIP_TEST_INCLUDES = -Itests/lwip -I$(LWIP_DIR)/src/include + # --- Bare metal (arm-none-eabi / Cortex-M, issue #447) ------------------------- # The WASI core (WASM_SRCS + WASIP2_EXTRA_SRCS) minus sqlite/db, the test # client and the WASI compat layer, built for a single-threaded firmware on @@ -584,9 +591,22 @@ $(BAREMETAL_HELLO): $(BAREMETAL_ARCHIVE) tests/baremetal/hello.c tests/baremetal tests/baremetal/hello.c tests/baremetal/board.c $(BAREMETAL_ARCHIVE) -lm $(BAREMETAL_CROSS)size $@ -baremetal-smoke: $(BAREMETAL_HELLO) +# The lwIP adapter on Cortex-M: CWIST + src/sys/port/lwip.c + the vendored +# lwIP core (tests/lwip/lwipopts.h), client and server over loopback. +BAREMETAL_LWIP_SRCS = tests/baremetal/hello_lwip.c tests/baremetal/board.c src/sys/port/lwip.c \ + tests/lwip/loop_client.c $(LWIP_CORE_SRCS) +$(BAREMETAL_BUILD_DIR)/hello-lwip.elf: $(BAREMETAL_ARCHIVE) $(BAREMETAL_LWIP_SRCS) tests/baremetal/stm32h743.ld \ + tests/lwip/lwipopts.h tests/lwip/arch/cc.h tests/lwip/loop_client.h include/cwist/sys/port/lwip.h + $(BAREMETAL_CROSS)gcc $(BAREMETAL_CFLAGS) $(LWIP_TEST_INCLUDES) -DBOARD_QEMU_USER -nostartfiles \ + --specs=nosys.specs -T tests/baremetal/stm32h743.ld -Wl,--gc-sections \ + -Wl,--no-warn-rwx-segments -Wl,-Map=$(@:.elf=.map) -o $@ \ + $(BAREMETAL_LWIP_SRCS) $(BAREMETAL_ARCHIVE) -lm + $(BAREMETAL_CROSS)size $@ + +baremetal-smoke: $(BAREMETAL_HELLO) $(BAREMETAL_BUILD_DIR)/hello-lwip.elf $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello.elf $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello-rtc.elf + $(QEMU_ARM) -cpu cortex-m7 $(BAREMETAL_BUILD_DIR)/hello-lwip.elf clean-baremetal: rm -rf $(BAREMETAL_BUILD_DIR) $(BAREMETAL_ARCHIVE) @@ -1264,6 +1284,7 @@ TEST_TARGETS = test_worker_affinity \ test_seq_auth \ test_sha256 \ test_baremetal_port \ + test_lwip_adapter \ test_error \ test_middleware_jwt \ test_inline_exports \ @@ -1580,6 +1601,14 @@ test_sha256: tests/test_sha256.c include/cwist/core/crypto/sha256.h $(CC) $(CFLAGS) -o $@ tests/test_sha256.c ./$@ +# lwIP adapter (src/sys/port/lwip.c) on the host: the vendored lwIP core in +# NO_SYS mode with tests/lwip/lwipopts.h, client and server over loopback. +test_lwip_adapter: $(LIB_NAME) tests/test_lwip_adapter.c src/sys/port/lwip.c include/cwist/sys/port/lwip.h \ + tests/lwip/loop_client.c tests/lwip/loop_client.h tests/lwip/lwipopts.h tests/lwip/arch/cc.h + $(CC) $(CFLAGS) $(LWIP_TEST_INCLUDES) -o $@ tests/test_lwip_adapter.c src/sys/port/lwip.c \ + tests/lwip/loop_client.c $(LWIP_CORE_SRCS) $(LIB_NAME) $(LIBS) + ./$@ + # Bare-metal port layer on the host: siphash.c and baremetal_time.c built as # CWIST_BAREMETAL (no libcwist.a), with baremetal_time.c's libc names renamed # so they do not collide with the host's. diff --git a/docs/api/v4.0-api-baseline.txt b/docs/api/v4.0-api-baseline.txt index 530e07fc2..d1b1bf4b3 100644 --- a/docs/api/v4.0-api-baseline.txt +++ b/docs/api/v4.0-api-baseline.txt @@ -4,7 +4,7 @@ # (parameter names dropped) and struct/union/enum layouts in member order. # `make api-check` (CI) fails when the headers no longer match this file. -== functions (783) == +== functions (787) == cwist_accept_socket cwist_alloc cwist_alloc_array @@ -502,6 +502,10 @@ cwist_logger_log cwist_logger_set_colors cwist_logger_set_level cwist_logger_warn +cwist_lwip_active_connections +cwist_lwip_close +cwist_lwip_config_init +cwist_lwip_listen cwist_make_socket_ipv4 cwist_malloc cwist_malloc_shim @@ -789,7 +793,7 @@ cwist_zod_parse cwist_zod_print_errors cwist_zod_validate -== function signatures (784) == +== function signatures (788) == cwist_accept_socket: cwist_error_t (int, struct sockaddr *, void (*)(int client_fd, void *ctx), void *) cwist_alloc: void * (size_t) cwist_alloc_array: void * (size_t, size_t) @@ -1288,6 +1292,10 @@ cwist_logger_log: void (cwist_logger *, cwist_log_level_t, const char *, ...) cwist_logger_set_colors: void (cwist_logger *, bool) cwist_logger_set_level: void (cwist_logger *, cwist_log_level_t) cwist_logger_warn: void (cwist_logger *, const char *, ...) +cwist_lwip_active_connections: size_t (const cwist_lwip_server *) +cwist_lwip_close: void (cwist_lwip_server *) +cwist_lwip_config_init: void (cwist_lwip_config *) +cwist_lwip_listen: cwist_lwip_server * (cwist_app *, const cwist_lwip_config *) cwist_make_socket_ipv4: int (struct sockaddr_in *, const char *, uint16_t, uint16_t) cwist_malloc: void * (size_t) cwist_malloc_shim: void * (size_t) @@ -1599,7 +1607,7 @@ cwist_wasm_component_dispose: void (void *) cwist_wasm_dispatch_memory: const char * (cwist_app *, const char *, size_t, size_t *) cwist_wasm_free: void (void *) -== types (struct/union/enum names) (153) == +== types (struct/union/enum names) (155) == __natsConnection __prim_cwist_error_t bdr_blob_t @@ -1688,6 +1696,8 @@ cwist_json_builder cwist_jwt_chunk cwist_jwt_claims cwist_logger +cwist_lwip_config +cwist_lwip_server cwist_metric cwist_metric_id cwist_metric_type @@ -1754,7 +1764,7 @@ cwist_zod_result quic_conn_fc quic_stream_fc -== typedefs (207) == +== typedefs (209) == __prim_cwist_error_t bdr_blob_t bdr_entry_t @@ -1868,6 +1878,8 @@ cwist_jwt_claims cwist_log_format_t cwist_log_level_t cwist_logger +cwist_lwip_config +cwist_lwip_server cwist_macro_log_level_t cwist_metric_id_t cwist_metric_t diff --git a/include/cwist/sys/port/lwip.h b/include/cwist/sys/port/lwip.h new file mode 100644 index 000000000..8be276b5f --- /dev/null +++ b/include/cwist/sys/port/lwip.h @@ -0,0 +1,99 @@ +/** + * @file lwip.h + * @brief Serve a CWIST app over lwIP's raw TCP API (issue #447 Phase 3). + * + * For targets without BSD sockets (bare metal, or an RTOS running lwIP in + * NO_SYS / tcpip-thread mode): the adapter owns a listening tcp_pcb, frames + * HTTP/1.1 requests out of the received pbufs, runs them through the app's + * router and middleware (cwist_app_dispatch), and streams the serialized + * response back with tcp_write/tcp_sent back-pressure. + * + * Build: compile src/sys/port/lwip.c into the firmware next to lwIP, with + * the firmware's lwipopts.h on the include path (the adapter depends on its + * TCP options). It needs LWIP_TCP and LWIP_CALLBACK_API, and uses only the + * raw API, so it works under NO_SYS=1. With SO_REUSE=1 the listener sets + * SOF_REUSEADDR, so it can be re-created while closed connections sit in + * TIME_WAIT. + * + * Context: every call here, and every callback the adapter installs, runs in + * lwIP's core context: the main loop for NO_SYS=1, the tcpip thread + * otherwise (wrap calls in tcpip_callback()). Handlers therefore run there + * too; keep them short. + * + * Behaviour: + * - Request bodies are framed by Content-Length. Transfer-Encoding is + * answered with 501, Content-Length together with Transfer-Encoding or an + * invalid/conflicting Content-Length with 400, an oversized header block + * with 431 and an oversized body with 413; the connection then closes. + * - Expect: 100-continue gets an interim 100 before the body is read. + * - Keep-alive follows the request and the response (as on the socket + * path); pipelined requests are answered in order, one at a time. + * - Responses are sent zero-copy from the serialized buffer, which is + * released once the peer has acknowledged it. + * - Received data beyond the request limits is refused back to lwIP while + * a response is in flight, so the TCP window throttles the peer. + * - Connections idle longer than idle_timeout_ms, or beyond max_conns at + * accept time, are closed (the latter aborted). + * - Deferred (cwist_async) and upgraded (WebSocket) responses are not + * supported here. + */ + +#ifndef __CWIST_SYS_PORT_LWIP_H__ +#define __CWIST_SYS_PORT_LWIP_H__ + +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Listener settings; start from cwist_lwip_config_init(). */ +typedef struct cwist_lwip_config { + uint16_t port; /**< TCP port to listen on (default 80). */ + uint16_t max_conns; /**< Concurrent connections; extra accepts are aborted (default 4). */ + size_t max_header_bytes; /**< Request line + headers + CRLFCRLF cap; 431 above (default 4096). */ + size_t max_body_bytes; /**< Content-Length cap; 413 above (default 16384). */ + uint32_t idle_timeout_ms; /**< Close a connection with no traffic for this long; 0 = never (default 30000). */ + bool keep_alive; /**< Allow persistent connections (default true). */ +} cwist_lwip_config; + +/** @brief Opaque listener handle returned by cwist_lwip_listen(). */ +typedef struct cwist_lwip_server cwist_lwip_server; + +/** + * @brief Fill @p cfg with the defaults documented on cwist_lwip_config. + * @param cfg Configuration to initialize. + */ +void cwist_lwip_config_init(cwist_lwip_config *cfg); + +/** + * @brief Bind a listening tcp_pcb on @p cfg->port (any address) and serve @p app on it. + * @param app Application to dispatch to; must outlive the server. + * @param cfg Settings, copied; NULL means the defaults. + * @return Server handle, or NULL when the pcb cannot be created, bound or + * put into the listen state, or on allocation failure. + */ +cwist_lwip_server *cwist_lwip_listen(cwist_app *app, const cwist_lwip_config *cfg); + +/** + * @brief Stop listening and abort every open connection, then free @p srv. + * @param srv Server returned by cwist_lwip_listen(); NULL is a no-op. + */ +void cwist_lwip_close(cwist_lwip_server *srv); + +/** + * @brief Number of connections the server currently holds open. + * @param srv Server handle. + * @return Open connection count (0 for NULL). + */ +size_t cwist_lwip_active_connections(const cwist_lwip_server *srv); + +#ifdef __cplusplus +} +#endif + +#endif /* __CWIST_SYS_PORT_LWIP_H__ */ diff --git a/lib/lwip b/lib/lwip new file mode 160000 index 000000000..77dcd25a7 --- /dev/null +++ b/lib/lwip @@ -0,0 +1 @@ +Subproject commit 77dcd25a72509eb83f72b033d219b1d40cd8eb95 diff --git a/src/sys/port/lwip.c b/src/sys/port/lwip.c new file mode 100644 index 000000000..697a4927d --- /dev/null +++ b/src/sys/port/lwip.c @@ -0,0 +1,548 @@ +/** + * @file lwip.c + * @brief CWIST over lwIP's raw TCP API; see . + * + * Not part of libcwist: the firmware compiles this file next to lwIP so it + * picks up the firmware's lwipopts.h. Uses only public CWIST API (parse, + * dispatch, serialize), which keeps it independent of the socket runtime. + * + * One connection runs one exchange at a time: + * + * recv -> rx buffer -> frame (Content-Length) -> parse -> dispatch + * -> serialize into tx -> tcp_write as tcp_sndbuf allows + * -> tcp_sent acks drain tx -> next pipelined request or close + * + * tx is written without TCP_WRITE_FLAG_COPY, so lwIP references it until + * the peer acknowledges; it is freed only once tx_acked reaches tx_len. + */ + +#include + +#include +#include + +#include "lwip/opt.h" +#include "lwip/sys.h" +#include "lwip/tcp.h" + +#include +#include +#include + +/* lwip/opt.h always defines both; testing for it keeps tools that analyze + * this file without the firmware's lwIP tree (cppcheck) off the #error. */ +#if defined(LWIP_TCP) && (!LWIP_TCP || !LWIP_CALLBACK_API) +#error "cwist lwIP adapter needs LWIP_TCP and LWIP_CALLBACK_API" +#endif + +/* tcp_poll interval in TCP coarse-timer ticks (TCP_TMR_INTERVAL * 2 = 500 ms). */ +#define CWIST_LWIP_POLL_TICKS 2 + +static const char CWIST_LWIP_100_CONTINUE[] = "HTTP/1.1 100 Continue\r\n\r\n"; + +typedef struct cwist_lwip_conn cwist_lwip_conn; + +struct cwist_lwip_server { + cwist_app *app; + cwist_lwip_config cfg; + struct tcp_pcb *listen_pcb; + cwist_lwip_conn *conns; + size_t nconns; +}; + +struct cwist_lwip_conn { + cwist_lwip_server *srv; + struct tcp_pcb *pcb; + cwist_lwip_conn *next; + + char *rx; + size_t rx_len; + size_t rx_cap; + + char *tx; /* serialized response; NULL when idle */ + size_t tx_len; + size_t tx_queued; /* bytes handed to tcp_write */ + size_t tx_acked; /* bytes the peer acknowledged */ + size_t ack_skip; /* acked bytes that belong to an interim 100 */ + + bool close_after_tx; /* Connection: close, error reply, or peer FIN */ + bool peer_closed; + bool continue_sent; + u32_t last_active_ms; +}; + +typedef enum { + FRAME_NEED_MORE, + FRAME_COMPLETE, + FRAME_ERROR, +} frame_result; + +/* --- framing ---------------------------------------------------------------- */ + +static const char *find_crlfcrlf(const char *buf, size_t len) { + for (size_t i = 0; i + 3 < len; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') + return buf + i; + } + return NULL; +} + +static bool header_is(const char *name, size_t name_len, const char *want) { + size_t n = strlen(want); + return name_len == n && strncasecmp(name, want, n) == 0; +} + +/* Trim optional whitespace (RFC 9110 5.6.3) around a field value. */ +static void trim_ows(const char **v, size_t *len) { + while (*len > 0 && (**v == ' ' || **v == '\t')) (*v)++, (*len)--; + while (*len > 0 && ((*v)[*len - 1] == ' ' || (*v)[*len - 1] == '\t')) (*len)--; +} + +/** + * Decide whether rx holds one complete request. Mirrors the parser's + * Content-Length / Transfer-Encoding rules (RFC 9112 6.3) so the adapter + * never hands the parser a different message boundary than it would pick. + */ +static frame_result frame_request(const cwist_lwip_conn *c, size_t *req_len, int *status, + bool *expect_continue) { + const cwist_lwip_config *cfg = &c->srv->cfg; + *expect_continue = false; + const char *end = find_crlfcrlf(c->rx, c->rx_len); + if (!end) { + if (c->rx_len > cfg->max_header_bytes) { + *status = CWIST_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE; + return FRAME_ERROR; + } + return FRAME_NEED_MORE; + } + size_t head_len = (size_t)(end - c->rx) + 4; + if (head_len > cfg->max_header_bytes) { + *status = CWIST_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE; + return FRAME_ERROR; + } + + bool cl_seen = false, te_seen = false; + size_t cl = 0; + const char *line = memchr(c->rx, '\n', head_len); + while (line && (size_t)(line + 1 - c->rx) < head_len - 2) { + line++; + const char *eol = memchr(line, '\r', (size_t)(end + 2 - line)); + if (!eol) break; + const char *colon = memchr(line, ':', (size_t)(eol - line)); + if (colon) { + size_t name_len = (size_t)(colon - line); + const char *v = colon + 1; + size_t vlen = (size_t)(eol - v); + trim_ows(&v, &vlen); + if (header_is(line, name_len, "Content-Length")) { + size_t n = 0; + if (vlen == 0) goto bad_request; + for (size_t i = 0; i < vlen; i++) { + if (v[i] < '0' || v[i] > '9' || n > (SIZE_MAX - 9) / 10) goto bad_request; + n = n * 10 + (size_t)(v[i] - '0'); + } + if (cl_seen && n != cl) goto bad_request; + cl_seen = true; + cl = n; + } else if (header_is(line, name_len, "Transfer-Encoding")) { + te_seen = true; + } else if (header_is(line, name_len, "Expect")) { + *expect_continue = vlen == 12 && strncasecmp(v, "100-continue", 12) == 0; + } + } + line = memchr(eol, '\n', (size_t)(end + 4 - eol)); + } + + if (te_seen) { + if (cl_seen) goto bad_request; + *status = CWIST_HTTP_NOT_IMPLEMENTED; /* chunked request bodies are not reassembled */ + return FRAME_ERROR; + } + if (cl > cfg->max_body_bytes) { + *status = CWIST_HTTP_PAYLOAD_TOO_LARGE; + return FRAME_ERROR; + } + if (c->rx_len - head_len < cl) return FRAME_NEED_MORE; + *req_len = head_len + cl; + return FRAME_COMPLETE; + +bad_request: + *status = CWIST_HTTP_BAD_REQUEST; + return FRAME_ERROR; +} + +/* --- connection lifecycle ---------------------------------------------------- */ + +static void conn_unlink(cwist_lwip_conn *c) { + cwist_lwip_server *srv = c->srv; + for (cwist_lwip_conn **pp = &srv->conns; *pp; pp = &(*pp)->next) { + if (*pp == c) { + *pp = c->next; + srv->nconns--; + break; + } + } +} + +static void conn_free(cwist_lwip_conn *c) { + conn_unlink(c); + cwist_free(c->rx); + cwist_free(c->tx); + cwist_free(c); +} + +static void conn_detach_pcb(cwist_lwip_conn *c) { + if (!c->pcb) return; + tcp_arg(c->pcb, NULL); + tcp_recv(c->pcb, NULL); + tcp_sent(c->pcb, NULL); + tcp_err(c->pcb, NULL); + tcp_poll(c->pcb, NULL, 0); +} + +/* Abort: lwIP frees the pcb; callers inside a pcb callback return ERR_ABRT. */ +static void conn_abort(cwist_lwip_conn *c) { + struct tcp_pcb *pcb = c->pcb; + conn_detach_pcb(c); + c->pcb = NULL; + conn_free(c); + if (pcb) tcp_abort(pcb); +} + +/* Graceful close; falls back to abort when lwIP is out of memory. + * @return ERR_OK, or ERR_ABRT when the pcb was aborted. */ +static err_t conn_close(cwist_lwip_conn *c) { + struct tcp_pcb *pcb = c->pcb; + conn_detach_pcb(c); + c->pcb = NULL; + conn_free(c); + if (pcb && tcp_close(pcb) != ERR_OK) { + tcp_abort(pcb); + return ERR_ABRT; + } + return ERR_OK; +} + +/* --- sending ------------------------------------------------------------------ */ + +/* Hand as much of tx to lwIP as the send buffer allows. + * @return ERR_OK, or ERR_ABRT when the connection was aborted. */ +static err_t conn_push(cwist_lwip_conn *c) { + while (c->tx && c->tx_queued < c->tx_len) { + size_t room = tcp_sndbuf(c->pcb); + if (room == 0) break; + size_t n = c->tx_len - c->tx_queued; + if (n > room) n = room; + if (n > 0xFFFF) n = 0xFFFF; + u8_t flags = (c->tx_queued + n < c->tx_len) ? TCP_WRITE_FLAG_MORE : 0; + err_t err = tcp_write(c->pcb, c->tx + c->tx_queued, (u16_t)n, flags); + if (err == ERR_MEM) break; /* retried from tcp_sent / tcp_poll */ + if (err != ERR_OK) { + conn_abort(c); + return ERR_ABRT; + } + c->tx_queued += n; + } + tcp_output(c->pcb); + return ERR_OK; +} + +/* Take ownership of a serialized response and start sending it. */ +static err_t conn_send(cwist_lwip_conn *c, char *buf, size_t len, bool close_after) { + c->tx = buf; + c->tx_len = len; + c->tx_queued = 0; + c->tx_acked = 0; + c->close_after_tx = c->close_after_tx || close_after; + return conn_push(c); +} + +/* Serialize a bodyless-by-default error reply and close after it is sent. */ +static err_t conn_send_error(cwist_lwip_conn *c, int status) { + c->rx_len = 0; /* the stream position is unknown past a framing error */ + cwist_http_response *res = cwist_http_response_create(); + char *out = NULL; + size_t out_len = 0; + if (res) { + const char *reason = cwist_http_status_reason(status); + res->status_code = (cwist_http_status_t)status; + res->keep_alive = false; + if (reason) cwist_http_response_set_body_ptr(res, reason, strlen(reason)); + if (cwist_http_response_serialize(res, &out, &out_len) != 0) out = NULL; + cwist_http_response_destroy(res); + } + if (!out) { + conn_abort(c); + return ERR_ABRT; + } + return conn_send(c, out, out_len, true); +} + +/* Length of the status line + headers + CRLFCRLF in a serialized response. */ +static size_t response_head_len(const char *buf, size_t len) { + const char *end = find_crlfcrlf(buf, len); + return end ? (size_t)(end - buf) + 4 : len; +} + +/* --- request processing ------------------------------------------------------- */ + +/* Answer the next complete request in rx, if any and if idle. + * @return ERR_OK, or ERR_ABRT when the connection was aborted. */ +static err_t conn_process(cwist_lwip_conn *c) { + if (c->tx || !c->pcb) return ERR_OK; + if (c->rx_len == 0) { + if (c->peer_closed) return conn_close(c); + return ERR_OK; + } + + size_t req_len = 0; + int status = 0; + bool expect_continue = false; + switch (frame_request(c, &req_len, &status, &expect_continue)) { + case FRAME_ERROR: + return conn_send_error(c, status); + case FRAME_NEED_MORE: + if (c->peer_closed) return conn_close(c); /* truncated request */ + if (expect_continue && !c->continue_sent) { + c->continue_sent = true; + if (tcp_write(c->pcb, CWIST_LWIP_100_CONTINUE, sizeof(CWIST_LWIP_100_CONTINUE) - 1, 0) == + ERR_OK) { + c->ack_skip += sizeof(CWIST_LWIP_100_CONTINUE) - 1; + tcp_output(c->pcb); + } + } + return ERR_OK; + case FRAME_COMPLETE: + break; + } + + cwist_http_request *req = cwist_http_parse_request_len(c->rx, req_len); + memmove(c->rx, c->rx + req_len, c->rx_len - req_len); + c->rx_len -= req_len; + c->continue_sent = false; + if (!req) return conn_send_error(c, CWIST_HTTP_BAD_REQUEST); + + cwist_http_response *res = cwist_http_response_create(); + if (!res) { + cwist_http_request_destroy(req); + return conn_send_error(c, CWIST_HTTP_INTERNAL_ERROR); + } + cwist_app_dispatch(c->srv->app, req, res); + if (res->stream_mode && !res->stream_ended) cwist_http_response_stream_end(res); + + bool keep = c->srv->cfg.keep_alive && req->keep_alive && res->keep_alive && !c->peer_closed; + bool head = req->method == CWIST_HTTP_HEAD; + cwist_http_request_destroy(req); + res->keep_alive = keep; + + char *out = NULL; + size_t out_len = 0; + int rc = cwist_http_response_serialize(res, &out, &out_len); + cwist_http_response_destroy(res); + if (rc != 0 || !out) return conn_send_error(c, CWIST_HTTP_INTERNAL_ERROR); + if (head) out_len = response_head_len(out, out_len); + return conn_send(c, out, out_len, !keep); +} + +/* --- lwIP callbacks ------------------------------------------------------------- */ + +static err_t on_sent(void *arg, struct tcp_pcb *pcb, u16_t len) { + cwist_lwip_conn *c = arg; + (void)pcb; + if (!c) return ERR_OK; + c->last_active_ms = sys_now(); + size_t acked = len; + size_t skip = acked < c->ack_skip ? acked : c->ack_skip; + c->ack_skip -= skip; + acked -= skip; + if (!c->tx) return ERR_OK; + c->tx_acked += acked; + if (c->tx_acked < c->tx_len) return conn_push(c); + + cwist_free(c->tx); + c->tx = NULL; + c->tx_len = c->tx_queued = c->tx_acked = 0; + if (c->close_after_tx) return conn_close(c); + return conn_process(c); /* next pipelined request */ +} + +static err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { + cwist_lwip_conn *c = arg; + if (!c) { + if (p) pbuf_free(p); + return ERR_OK; + } + if (!p) { /* FIN */ + c->peer_closed = true; + return conn_process(c); + } + if (err != ERR_OK) { + pbuf_free(p); + return err; + } + c->last_active_ms = sys_now(); + if (c->close_after_tx) { /* draining toward close: discard */ + tcp_recved(pcb, p->tot_len); + pbuf_free(p); + return ERR_OK; + } + + /* One request is at most header cap + body cap; keep one spare request + * worth of pipelined bytes while a response is in flight. */ + size_t limit = c->srv->cfg.max_header_bytes + c->srv->cfg.max_body_bytes; + size_t need = c->rx_len + p->tot_len; + if (need > limit + 1) { + if (c->tx) return ERR_MEM; /* lwIP keeps p and redelivers it later */ + need = limit + 1; /* enough to classify as 413/431 */ + } + if (need > c->rx_cap) { + size_t cap = c->rx_cap ? c->rx_cap : 512; + while (cap < need) cap *= 2; + if (cap > limit + 1) cap = limit + 1; + char *grown = cwist_realloc(c->rx, cap); + if (!grown) { + pbuf_free(p); + conn_abort(c); + return ERR_ABRT; + } + c->rx = grown; + c->rx_cap = cap; + } + size_t take = need - c->rx_len; + pbuf_copy_partial(p, c->rx + c->rx_len, (u16_t)take, 0); + c->rx_len += take; + tcp_recved(pcb, p->tot_len); + pbuf_free(p); + return conn_process(c); +} + +static void on_err(void *arg, err_t err) { + cwist_lwip_conn *c = arg; + (void)err; + if (!c) return; + c->pcb = NULL; /* already freed by lwIP */ + conn_free(c); +} + +static err_t on_poll(void *arg, struct tcp_pcb *pcb) { + cwist_lwip_conn *c = arg; + (void)pcb; + if (!c) return ERR_OK; + uint32_t timeout = c->srv->cfg.idle_timeout_ms; + if (timeout && (u32_t)(sys_now() - c->last_active_ms) >= timeout) return conn_close(c); + if (c->tx && c->tx_queued < c->tx_len) return conn_push(c); /* retry after ERR_MEM */ + return ERR_OK; +} + +static err_t on_accept(void *arg, struct tcp_pcb *pcb, err_t err) { + cwist_lwip_server *srv = arg; + if (err != ERR_OK || !pcb) return ERR_VAL; + if (srv->nconns >= srv->cfg.max_conns) { + tcp_abort(pcb); + return ERR_ABRT; + } + cwist_lwip_conn *c = cwist_alloc(sizeof(*c)); + if (!c) { + tcp_abort(pcb); + return ERR_ABRT; + } + memset(c, 0, sizeof(*c)); + c->srv = srv; + c->pcb = pcb; + c->last_active_ms = sys_now(); + c->next = srv->conns; + srv->conns = c; + srv->nconns++; + + tcp_arg(pcb, c); + tcp_recv(pcb, on_recv); + tcp_sent(pcb, on_sent); + tcp_err(pcb, on_err); + tcp_poll(pcb, on_poll, CWIST_LWIP_POLL_TICKS); + return ERR_OK; +} + +/* --- public API ----------------------------------------------------------------- */ + +/** + * @brief Fill @p cfg with the defaults documented on cwist_lwip_config. + * @param cfg Configuration to initialize. + */ +void cwist_lwip_config_init(cwist_lwip_config *cfg) { + if (!cfg) return; + cfg->port = 80; + cfg->max_conns = 4; + cfg->max_header_bytes = 4096; + cfg->max_body_bytes = 16384; + cfg->idle_timeout_ms = 30000; + cfg->keep_alive = true; +} + +/** + * @brief Bind a listening tcp_pcb on @p cfg->port and serve @p app on it. + * @param app Application to dispatch to; must outlive the server. + * @param cfg Settings, copied; NULL means the defaults. + * @return Server handle, or NULL on pcb or allocation failure. + */ +cwist_lwip_server *cwist_lwip_listen(cwist_app *app, const cwist_lwip_config *cfg) { + if (!app) return NULL; + cwist_lwip_server *srv = cwist_alloc(sizeof(*srv)); + if (!srv) return NULL; + memset(srv, 0, sizeof(*srv)); + srv->app = app; + if (cfg) + srv->cfg = *cfg; + else + cwist_lwip_config_init(&srv->cfg); + if (srv->cfg.max_conns == 0) srv->cfg.max_conns = 1; + + struct tcp_pcb *pcb = tcp_new_ip_type(IPADDR_TYPE_ANY); + if (!pcb) goto fail; +#if SO_REUSE + /* Restarting the listener must not wait out TIME_WAIT on connections the + * server closed (it is the active closer for Connection: close). */ + ip_set_option(pcb, SOF_REUSEADDR); +#endif + if (tcp_bind(pcb, IP_ANY_TYPE, srv->cfg.port) != ERR_OK) { + tcp_close(pcb); + goto fail; + } + struct tcp_pcb *lpcb = tcp_listen_with_backlog(pcb, srv->cfg.max_conns); + if (!lpcb) { + tcp_close(pcb); + goto fail; + } + srv->listen_pcb = lpcb; + tcp_arg(lpcb, srv); + tcp_accept(lpcb, on_accept); + return srv; + +fail: + cwist_free(srv); + return NULL; +} + +/** + * @brief Stop listening, abort every open connection, and free @p srv. + * @param srv Server returned by cwist_lwip_listen(); NULL is a no-op. + */ +void cwist_lwip_close(cwist_lwip_server *srv) { + if (!srv) return; + if (srv->listen_pcb) { + tcp_arg(srv->listen_pcb, NULL); + tcp_accept(srv->listen_pcb, NULL); + tcp_close(srv->listen_pcb); + srv->listen_pcb = NULL; + } + while (srv->conns) conn_abort(srv->conns); + cwist_free(srv); +} + +/** + * @brief Number of connections the server currently holds open. + * @param srv Server handle. + * @return Open connection count (0 for NULL). + */ +size_t cwist_lwip_active_connections(const cwist_lwip_server *srv) { + return srv ? srv->nconns : 0; +} diff --git a/tests/baremetal/hello_lwip.c b/tests/baremetal/hello_lwip.c new file mode 100644 index 000000000..77681cfdb --- /dev/null +++ b/tests/baremetal/hello_lwip.c @@ -0,0 +1,106 @@ +/* Bare-metal lwIP smoke (issue #447 Phase 3): the CWIST lwIP adapter and + * lwIP itself on Cortex-M, client and server pcbs over the loopback netif. + * Exercises keep-alive, pipelining, a body split across segments, a 413, + * and a response larger than the send window, then reports the heap/stack + * high-water marks for the whole stack (CWIST + lwIP). Run with + * `make baremetal-smoke`. */ +#include +#include + +#include "loop_client.h" + +#include +#include + +extern void board_log(const char *s); +extern size_t board_heap_high_water(void); +extern void board_stack_paint(void); +extern size_t board_stack_used(void); + +static char g_big[8192]; +static char g_echo[32]; + +static void hello_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cwist_http_response_set_body_ptr(res, "hello over lwip", 15); +} + +static void echo_handler(cwist_http_request *req, cwist_http_response *res) { + int n = snprintf(g_echo, sizeof g_echo, "len=%u", (unsigned)(req->body ? req->body->size : 0)); + cwist_http_response_set_body_ptr(res, g_echo, (size_t)n); +} + +static void big_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cwist_http_response_set_body_ptr(res, g_big, sizeof g_big); +} + +static int check(const char *what, int ok) { + board_log(ok ? "PASS " : "FAIL "); + board_log(what); + board_log("\n"); + return !ok; +} + +int main(void) { + board_stack_paint(); + for (size_t i = 0; i < sizeof g_big; i++) g_big[i] = (char)('a' + i % 26); + loop_init(); + + cwist_app *app = cwist_app_create(); + if (!app) return check("cwist_app_create", 0); + cwist_app_get(app, "/hello", hello_handler); + cwist_app_post(app, "/echo", echo_handler); + cwist_app_get(app, "/big", big_handler); + + cwist_lwip_config cfg; + cwist_lwip_config_init(&cfg); + cfg.max_body_bytes = 1024; + cwist_lwip_server *srv = cwist_lwip_listen(app, &cfg); + int fail = check("listen on :80", srv != NULL); + if (!srv) return fail; + + loop_client c; + fail |= check("client connects", loop_connect(&c, 80)); + static const char get[] = "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"; + loop_send(&c, get, sizeof get - 1); + fail |= check("GET /hello", loop_wait_responses(&c, 1, 2000) && strstr(c.rx, "hello over lwip")); + fail |= check("keep-alive", strstr(c.rx, "Connection: keep-alive") != NULL); + + static const char two[] = "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n" + "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 6\r\n\r\nab"; + loop_send(&c, two, sizeof two - 1); + loop_send(&c, "cdef", 4); + fail |= check("pipelined + split body", loop_wait_responses(&c, 3, 2000) && strstr(c.rx, "len=6")); + + static const char big[] = "GET /big HTTP/1.1\r\nHost: x\r\n\r\n"; + size_t before = c.rx_len; + loop_send(&c, big, sizeof big - 1); + int ok = loop_wait_responses(&c, 4, 5000); + const char *body = ok ? strstr(c.rx + before, "\r\n\r\n") : NULL; + fail |= check("8 KB response through a 2-segment window", + body && (size_t)(c.rx + c.rx_len - (body + 4)) == sizeof g_big && + memcmp(body + 4, g_big, sizeof g_big) == 0); + loop_close(&c); + + loop_client e; + loop_connect(&e, 80); + static const char huge[] = "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\n"; + loop_send(&e, huge, sizeof huge - 1); + fail |= check("413 above max_body_bytes", + loop_wait_responses(&e, 1, 2000) && strstr(e.rx, "HTTP/1.1 413") && + loop_wait_closed(&e, 2000)); + loop_close(&e); + + loop_run(200); + fail |= check("all connections released", cwist_lwip_active_connections(srv) == 0); + cwist_lwip_close(srv); + cwist_app_destroy(app); + + char buf[96]; + snprintf(buf, sizeof buf, "heap high water: %u bytes, stack used: %u bytes\n", + (unsigned)board_heap_high_water(), (unsigned)board_stack_used()); + board_log(buf); + board_log(fail ? "baremetal-lwip-smoke: FAIL\n" : "baremetal-lwip-smoke: PASS\n"); + return fail; +} diff --git a/tests/lwip/arch/cc.h b/tests/lwip/arch/cc.h new file mode 100644 index 000000000..05a33c643 --- /dev/null +++ b/tests/lwip/arch/cc.h @@ -0,0 +1,22 @@ +/* Minimal lwIP compiler/platform glue for the adapter tests (host and + * Cortex-M). lwip/arch.h supplies the integer types and byte order. */ +#ifndef CWIST_TEST_LWIP_ARCH_CC_H +#define CWIST_TEST_LWIP_ARCH_CC_H + +#include +#include + +#define LWIP_PLATFORM_DIAG(x) \ + do { \ + printf x; \ + } while (0) +#define LWIP_PLATFORM_ASSERT(x) \ + do { \ + printf("lwIP assertion \"%s\" failed at %s:%d\n", x, __FILE__, __LINE__); \ + abort(); \ + } while (0) + +/* Initial sequence numbers and ports only; not a security boundary here. */ +#define LWIP_RAND() ((u32_t)rand()) + +#endif /* CWIST_TEST_LWIP_ARCH_CC_H */ diff --git a/tests/lwip/loop_client.c b/tests/lwip/loop_client.c new file mode 100644 index 000000000..d0b5a3739 --- /dev/null +++ b/tests/lwip/loop_client.c @@ -0,0 +1,170 @@ +/* See loop_client.h. */ +#include "loop_client.h" + +#include "lwip/init.h" +#include "lwip/ip_addr.h" +#include "lwip/netif.h" +#include "lwip/sys.h" +#include "lwip/tcp.h" +#include "lwip/timeouts.h" + +#include +#include + +static uint32_t g_now_ms; + +u32_t sys_now(void) { return g_now_ms; } + +uint32_t loop_now(void) { return g_now_ms; } + +void loop_init(void) { + g_now_ms = 1; + lwip_init(); +} + +void loop_run(uint32_t ms) { + for (uint32_t t = 0; t <= ms; t += 5) { + netif_poll_all(); + sys_check_timeouts(); + g_now_ms += 5; + } + netif_poll_all(); +} + +static void flush_pending(loop_client *c) { + while (c->pcb && c->pending_len > 0) { + size_t room = tcp_sndbuf(c->pcb); + if (room == 0) break; + size_t n = c->pending_len < room ? c->pending_len : room; + if (tcp_write(c->pcb, c->pending, (u16_t)n, 0) != ERR_OK) break; + c->pending += n; + c->pending_len -= n; + } + if (c->pcb) tcp_output(c->pcb); +} + +static err_t cl_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { + loop_client *c = arg; + (void)err; + if (!p) { + c->closed = true; + return ERR_OK; + } + if (c->rx_len + p->tot_len + 1 > c->rx_cap) { + size_t cap = c->rx_cap ? c->rx_cap : 1024; + while (cap < c->rx_len + p->tot_len + 1) cap *= 2; + c->rx = realloc(c->rx, cap); + c->rx_cap = cap; + } + pbuf_copy_partial(p, c->rx + c->rx_len, p->tot_len, 0); + c->rx_len += p->tot_len; + c->rx[c->rx_len] = '\0'; + tcp_recved(pcb, p->tot_len); + pbuf_free(p); + return ERR_OK; +} + +static err_t cl_sent(void *arg, struct tcp_pcb *pcb, u16_t len) { + (void)pcb; + (void)len; + flush_pending(arg); + return ERR_OK; +} + +static void cl_err(void *arg, err_t err) { + loop_client *c = arg; + (void)err; + c->pcb = NULL; + c->errored = true; +} + +static err_t cl_connected(void *arg, struct tcp_pcb *pcb, err_t err) { + loop_client *c = arg; + (void)pcb; + c->connected = err == ERR_OK; + return ERR_OK; +} + +bool loop_connect(loop_client *c, uint16_t port) { + memset(c, 0, sizeof(*c)); + c->pcb = tcp_new(); + if (!c->pcb) return false; + tcp_arg(c->pcb, c); + tcp_recv(c->pcb, cl_recv); + tcp_sent(c->pcb, cl_sent); + tcp_err(c->pcb, cl_err); + ip_addr_t lo; + IP_ADDR4(&lo, 127, 0, 0, 1); + if (tcp_connect(c->pcb, &lo, port, cl_connected) != ERR_OK) return false; + for (int i = 0; i < 100 && !c->connected && !c->errored; i++) loop_run(5); + return c->connected && !c->errored; +} + +void loop_send(loop_client *c, const char *data, size_t len) { + c->pending = data; + c->pending_len = len; + flush_pending(c); + for (int i = 0; i < 200 && c->pcb && c->pending_len > 0; i++) loop_run(5); + loop_run(20); +} + +int loop_response_count(const loop_client *c) { + int n = 0; + for (const char *p = c->rx; p && (p = strstr(p, "HTTP/1.1 ")) != NULL; p += 9) { + if (strncmp(p + 9, "100 ", 4) != 0) n++; + } + return n; +} + +/* A response is complete when its head ended and Content-Length body bytes followed. */ +static int complete_responses(const loop_client *c) { + int n = 0; + const char *p = c->rx, *end = c->rx + c->rx_len; + while (p && p < end) { + const char *head = strstr(p, "\r\n\r\n"); + if (!head) break; + head += 4; + if (strncmp(p, "HTTP/1.1 100 ", 13) == 0) { + p = head; + continue; + } + size_t cl = 0; + const char *h = strstr(p, "Content-Length: "); + if (h && h < head) cl = strtoul(h + 16, NULL, 10); + if ((size_t)(end - head) < cl) break; + n++; + p = head + cl; + } + return n; +} + +bool loop_wait_responses(loop_client *c, int n, uint32_t max_ms) { + for (uint32_t t = 0; t < max_ms; t += 10) { + if (!c->rx || complete_responses(c) < n) { + loop_run(10); + continue; + } + return true; + } + return c->rx && complete_responses(c) >= n; +} + +bool loop_wait_closed(loop_client *c, uint32_t max_ms) { + for (uint32_t t = 0; t < max_ms && !c->closed && !c->errored; t += 10) loop_run(10); + return c->closed || c->errored; +} + +void loop_close(loop_client *c) { + if (c->pcb) { + tcp_arg(c->pcb, NULL); + tcp_recv(c->pcb, NULL); + tcp_sent(c->pcb, NULL); + tcp_err(c->pcb, NULL); + if (tcp_close(c->pcb) != ERR_OK) tcp_abort(c->pcb); + c->pcb = NULL; + } + free(c->rx); + c->rx = NULL; + c->rx_len = c->rx_cap = 0; + loop_run(20); +} diff --git a/tests/lwip/loop_client.h b/tests/lwip/loop_client.h new file mode 100644 index 000000000..d790c4db7 --- /dev/null +++ b/tests/lwip/loop_client.h @@ -0,0 +1,44 @@ +/* In-process HTTP client for the lwIP adapter tests: raw-API client pcbs + * talking to the adapter over lwIP's loopback netif, driven by a fake + * millisecond clock (NO_SYS, no threads). Shared by tests/test_lwip_adapter.c + * (host) and tests/baremetal/hello_lwip.c (Cortex-M under qemu-arm). */ +#ifndef CWIST_TEST_LOOP_CLIENT_H +#define CWIST_TEST_LOOP_CLIENT_H + +#include +#include +#include + +typedef struct loop_client { + struct tcp_pcb *pcb; + bool connected; + bool closed; /* peer sent FIN */ + bool errored; /* reset or aborted */ + char *rx; /* everything received, NUL-terminated */ + size_t rx_len; + size_t rx_cap; + const char *pending; /* bytes not yet accepted by tcp_write */ + size_t pending_len; +} loop_client; + +/** lwip_init() and reset the fake clock. */ +void loop_init(void); +/** Advance the fake clock by @p ms, delivering loopback packets and timers. */ +void loop_run(uint32_t ms); +/** Current fake time (also what sys_now() returns). */ +uint32_t loop_now(void); + +/** Connect to 127.0.0.1:@p port; runs the loop until connected or failed. */ +bool loop_connect(loop_client *c, uint16_t port); +/** Queue @p len bytes (kept by reference until sent) and run until flushed. */ +void loop_send(loop_client *c, const char *data, size_t len); +/** Run until @p c has received at least @p n responses or @p max_ms elapse. */ +bool loop_wait_responses(loop_client *c, int n, uint32_t max_ms); +/** Run until the peer closes or resets @p c, or @p max_ms elapse. */ +bool loop_wait_closed(loop_client *c, uint32_t max_ms); +/** Count complete "HTTP/1.1 " status lines (excluding 100 Continue) in rx. */ +int loop_response_count(const loop_client *c); +/** Close (or abort) the client pcb and free its buffer. */ +void loop_close(loop_client *c); + +#endif /* CWIST_TEST_LOOP_CLIENT_H */ diff --git a/tests/lwip/lwipopts.h b/tests/lwip/lwipopts.h new file mode 100644 index 000000000..f698243d4 --- /dev/null +++ b/tests/lwip/lwipopts.h @@ -0,0 +1,54 @@ +/* lwIP configuration for the CWIST adapter tests (tests/test_lwip_adapter.c + * on the host, tests/baremetal/hello_lwip.c on Cortex-M): NO_SYS raw API, + * IPv4 TCP only, traffic carried by the loopback netif so client and server + * pcbs run in one process. Sized like a small MCU: a few connections, a + * 2-segment send window. */ +#ifndef CWIST_TEST_LWIPOPTS_H +#define CWIST_TEST_LWIPOPTS_H + +#define NO_SYS 1 +#define SYS_LIGHTWEIGHT_PROT 0 +#define LWIP_TIMERS 1 +#define LWIP_NETCONN 0 +#define LWIP_SOCKET 0 + +#define LWIP_IPV4 1 +#define LWIP_IPV6 0 +#define LWIP_TCP 1 +#define LWIP_UDP 0 +#define LWIP_ICMP 0 +#define LWIP_RAW 0 +#define LWIP_DHCP 0 +#define LWIP_ARP 0 +#define LWIP_ETHERNET 0 +#define LWIP_DNS 0 +#define LWIP_IGMP 0 + +#define LWIP_NETIF_LOOPBACK 1 +#define LWIP_LOOPBACK_MAX_PBUFS 0 + +#define MEM_ALIGNMENT __SIZEOF_POINTER__ /* 4 on Cortex-M, 8 on 64-bit hosts */ +#define MEM_SIZE (24 * 1024) +#define MEMP_NUM_PBUF 32 +#define MEMP_NUM_TCP_PCB 8 +#define MEMP_NUM_TCP_PCB_LISTEN 2 +#define MEMP_NUM_TCP_SEG 32 +#define PBUF_POOL_SIZE 16 + +#define TCP_MSS 1460 +#define TCP_SND_BUF (2 * TCP_MSS) +#define TCP_SND_QUEUELEN ((4 * TCP_SND_BUF) / TCP_MSS) +#define TCP_WND (4 * TCP_MSS) +#define TCP_LISTEN_BACKLOG 1 +#define SO_REUSE 1 + +/* Host build: CWIST's public headers already pull in , whose + * htons()/ntohl() macros lwIP must not redefine. */ +#if defined(__linux__) || defined(__APPLE__) || defined(__FreeBSD__) +#define LWIP_DONT_PROVIDE_BYTEORDER_FUNCTIONS 1 +#endif + +#define LWIP_STATS 0 +#define LWIP_DEBUG 0 + +#endif /* CWIST_TEST_LWIPOPTS_H */ diff --git a/tests/test_lwip_adapter.c b/tests/test_lwip_adapter.c new file mode 100644 index 000000000..8e1736251 --- /dev/null +++ b/tests/test_lwip_adapter.c @@ -0,0 +1,247 @@ +/* lwIP adapter (src/sys/port/lwip.c, issue #447 Phase 3) on the host: a + * vendored lwIP (lib/lwip) in NO_SYS mode carries client and server pcbs + * over its loopback netif, so every byte goes through real lwIP TCP. Covers + * keep-alive, pipelining, Content-Length bodies split across segments, + * Expect: 100-continue, the 400/413/431/501 framing errors, Connection: + * close, responses larger than the send window, HEAD, the connection cap, + * the idle timeout, and teardown. */ +#include +#include + +#include "lwip/tcp.h" +#include "loop_client.h" + +#include +#include +#include + +static char g_big[20000]; +static char g_echo[64]; + +static void hello_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cwist_http_response_set_body_ptr(res, "hello", 5); +} + +static void echo_handler(cwist_http_request *req, cwist_http_response *res) { + int n = snprintf(g_echo, sizeof g_echo, "len=%zu", req->body ? req->body->size : 0); + cwist_http_response_set_body_ptr(res, g_echo, (size_t)n); +} + +static void big_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + cwist_http_response_set_body_ptr(res, g_big, sizeof g_big); +} + +static void close_handler(cwist_http_request *req, cwist_http_response *res) { + (void)req; + res->keep_alive = false; + cwist_http_response_set_body_ptr(res, "bye", 3); +} + +static cwist_app *g_app; +static cwist_lwip_server *g_srv; + +static void start(const cwist_lwip_config *cfg) { + g_srv = cwist_lwip_listen(g_app, cfg); + assert(g_srv); +} + +static void stop(void) { + cwist_lwip_close(g_srv); + g_srv = NULL; + loop_run(50); +} + +static const char GET_HELLO[] = "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"; + +static void test_keep_alive_and_pipelining(void) { + start(NULL); + loop_client c; + assert(loop_connect(&c, 80)); + loop_send(&c, GET_HELLO, sizeof GET_HELLO - 1); + assert(loop_wait_responses(&c, 1, 2000)); + assert(strstr(c.rx, "HTTP/1.1 200") && strstr(c.rx, "Connection: keep-alive")); + assert(strstr(c.rx, "\r\n\r\nhello")); + assert(cwist_lwip_active_connections(g_srv) == 1); + + /* two requests in one segment: answered in order on the same connection */ + static const char two[] = "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n" + "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\n\r\nabc"; + loop_send(&c, two, sizeof two - 1); + assert(loop_wait_responses(&c, 3, 2000)); + char *echo = strstr(c.rx, "len=3"); + assert(echo && echo > strstr(strstr(c.rx, "hello") + 5, "hello")); + assert(!c.closed); + loop_close(&c); + stop(); +} + +static void test_body_split_and_expect_continue(void) { + start(NULL); + loop_client c; + assert(loop_connect(&c, 80)); + static const char head[] = "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n" + "Expect: 100-continue\r\n\r\n"; + loop_send(&c, head, sizeof head - 1); + loop_run(100); + assert(c.rx && strstr(c.rx, "HTTP/1.1 100 Continue\r\n\r\n")); + assert(loop_response_count(&c) == 0); + loop_send(&c, "01234", 5); + loop_run(100); + assert(loop_response_count(&c) == 0); + loop_send(&c, "56789", 5); + assert(loop_wait_responses(&c, 1, 2000)); + assert(strstr(c.rx, "len=10")); + loop_close(&c); + stop(); +} + +static void expect_error(const char *req, const char *status_line) { + loop_client c; + assert(loop_connect(&c, 80)); + loop_send(&c, req, strlen(req)); + assert(loop_wait_responses(&c, 1, 2000)); + if (!strstr(c.rx, status_line)) { + fprintf(stderr, "expected %s, got:\n%s\n", status_line, c.rx); + assert(0); + } + assert(strstr(c.rx, "Connection: close")); + assert(loop_wait_closed(&c, 2000)); + loop_close(&c); +} + +static void test_framing_errors(void) { + cwist_lwip_config cfg; + cwist_lwip_config_init(&cfg); + cfg.max_header_bytes = 256; + cfg.max_body_bytes = 64; + start(&cfg); + expect_error("POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + "HTTP/1.1 501"); + expect_error("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 2\r\n" + "Transfer-Encoding: chunked\r\n\r\nab", + "HTTP/1.1 400"); + expect_error("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 1x\r\n\r\na", "HTTP/1.1 400"); + expect_error("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\nab", + "HTTP/1.1 400"); + expect_error("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 65\r\n\r\n", "HTTP/1.1 413"); + char big_head[400]; + int n = snprintf(big_head, sizeof big_head, "GET /hello HTTP/1.1\r\nHost: x\r\nX-Pad: "); + memset(big_head + n, 'a', 300); + memcpy(big_head + n + 300, "\r\n\r\n", 5); + expect_error(big_head, "HTTP/1.1 431"); + /* no CRLFCRLF at all, already past the cap */ + big_head[n + 300] = '\0'; + expect_error(big_head, "HTTP/1.1 431"); + /* a request the parser rejects (missing Host) after framing succeeds */ + expect_error("GET /hello HTTP/1.1\r\n\r\n", "HTTP/1.1 400"); + stop(); +} + +static void test_close_large_and_head(void) { + start(NULL); + loop_client c; + assert(loop_connect(&c, 80)); + static const char big[] = "GET /big HTTP/1.1\r\nHost: x\r\n\r\n"; + loop_send(&c, big, sizeof big - 1); + assert(loop_wait_responses(&c, 1, 5000)); + char *body = strstr(c.rx, "\r\n\r\n") + 4; + assert((size_t)(c.rx + c.rx_len - body) == sizeof g_big); + assert(memcmp(body, g_big, sizeof g_big) == 0); + + static const char head[] = "HEAD /hello HTTP/1.1\r\nHost: x\r\n\r\n"; + size_t before = c.rx_len; + loop_send(&c, head, sizeof head - 1); + loop_run(200); + char *h = c.rx + before; + assert(strncmp(h, "HTTP/1.1 ", 9) == 0); + assert(strcmp(h + strlen(h) - 4, "\r\n\r\n") == 0); /* head only, no body bytes */ + + static const char bye[] = "GET /close HTTP/1.1\r\nHost: x\r\n\r\n"; + loop_send(&c, bye, sizeof bye - 1); + assert(loop_wait_closed(&c, 2000)); + assert(strstr(c.rx, "Connection: close") && strstr(c.rx, "\r\n\r\nbye")); + loop_close(&c); + loop_run(100); + assert(cwist_lwip_active_connections(g_srv) == 0); + + /* HTTP/1.0 without keep-alive closes too */ + assert(loop_connect(&c, 80)); + static const char v10[] = "GET /hello HTTP/1.0\r\n\r\n"; + loop_send(&c, v10, sizeof v10 - 1); + assert(loop_wait_closed(&c, 2000)); + assert(strstr(c.rx, "\r\n\r\nhello")); + loop_close(&c); + stop(); +} + +static void test_conn_cap_idle_and_teardown(void) { + cwist_lwip_config cfg; + cwist_lwip_config_init(&cfg); + cfg.max_conns = 2; + cfg.idle_timeout_ms = 3000; + start(&cfg); + loop_client a, b, extra; + assert(loop_connect(&a, 80)); + assert(loop_connect(&b, 80)); + loop_run(50); + assert(cwist_lwip_active_connections(g_srv) == 2); + loop_connect(&extra, 80); /* accepted by TCP, then aborted by the adapter */ + assert(loop_wait_closed(&extra, 2000) && extra.errored); + assert(cwist_lwip_active_connections(g_srv) == 2); + loop_close(&extra); + + /* traffic on a keeps it alive while b idles out */ + for (int i = 0; i < 4; i++) { + loop_send(&a, GET_HELLO, sizeof GET_HELLO - 1); + assert(loop_wait_responses(&a, i + 1, 2000)); + loop_run(900); + } + assert(loop_wait_closed(&b, 2000) && !a.closed); + assert(cwist_lwip_active_connections(g_srv) == 1); + loop_close(&b); + + /* teardown aborts what is still open */ + stop(); + loop_run(100); + assert(a.errored || a.closed); + loop_close(&a); +} + +static void test_listen_defaults_and_failure(void) { + cwist_lwip_config cfg; + cwist_lwip_config_init(&cfg); + assert(cfg.port == 80 && cfg.max_conns == 4 && cfg.keep_alive); + assert(cfg.max_header_bytes == 4096 && cfg.max_body_bytes == 16384); + assert(cfg.idle_timeout_ms == 30000); + assert(cwist_lwip_listen(NULL, &cfg) == NULL); + cwist_lwip_server *first = cwist_lwip_listen(g_app, &cfg); + assert(first); + assert(cwist_lwip_listen(g_app, &cfg) == NULL); /* port already bound */ + assert(cwist_lwip_active_connections(NULL) == 0); + cwist_lwip_close(first); + cwist_lwip_close(NULL); +} + +int main(void) { + for (size_t i = 0; i < sizeof g_big; i++) g_big[i] = (char)('a' + i % 26); + loop_init(); + g_app = cwist_app_create(); + assert(g_app); + cwist_app_get(g_app, "/hello", hello_handler); + cwist_app_post(g_app, "/echo", echo_handler); + cwist_app_get(g_app, "/big", big_handler); + cwist_app_get(g_app, "/close", close_handler); + + test_listen_defaults_and_failure(); + test_keep_alive_and_pipelining(); + test_body_split_and_expect_continue(); + test_framing_errors(); + test_close_large_and_head(); + test_conn_cap_idle_and_teardown(); + + cwist_app_destroy(g_app); + printf("test_lwip_adapter: all passed\n"); + return 0; +} From 21160f87b19c2497d9726fd80320f50d2d9a6275 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Tue, 27 Oct 2026 09:56:35 +0900 Subject: [PATCH 2/3] feat(middleware): add shared Redis rate limiting (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(middleware): add shared Redis rate limiting * test(redis): fix GCC buffer and pipe declarations * fix(redis): Potential fix for pull request finding 'CodeQL / Potentially overflowing call to snprintf' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(redis): validate frame encoding before pooled I/O Preserve the upstream Autofix commit and supersede its void encoder with the independently reviewed checked implementation. Reject formatting failures and capacity mismatches before acquiring a connection; cover bounds and formatter failures without suppressing CodeQL. --------- Co-authored-by: 이윤진(Lee Yunjin) <168180007+gg582@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Lee Yunjin (cherry picked from commit 29df1ac6461152f2c522cf4ffc6d3a8a191cc728) --- .github/workflows/interop.yml | 22 + .gitignore | 3 + Makefile | 23 + docs/api/middleware.md | 78 ++ docs/api/v4.0-api-baseline.txt | 18 +- include/cwist/net/redis/cwist_redis.h | 52 ++ include/cwist/sys/app/middleware.h | 33 +- scripts/ci/api-untested-baseline.txt | 2 - scripts/ci/redis_rate_limit_e2e.py | 122 +++ src/net/redis/cwist_redis.c | 596 +++++++++++++- src/net/redis/redis_frame_internal.h | 32 + src/sys/app/rate_limit_redis.c | 156 ++++ tests/test_rate_limit_redis.c | 300 +++++++ tests/test_redis_frame.c | 69 ++ tests/test_redis_timeout.c | 1097 +++++++++++++++++++++++++ 15 files changed, 2593 insertions(+), 10 deletions(-) create mode 100755 scripts/ci/redis_rate_limit_e2e.py create mode 100644 src/net/redis/redis_frame_internal.h create mode 100644 src/sys/app/rate_limit_redis.c create mode 100644 tests/test_rate_limit_redis.c create mode 100644 tests/test_redis_frame.c create mode 100644 tests/test_redis_timeout.c diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index cc49d56b1..d9bcc6891 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -80,3 +80,25 @@ jobs: - name: Test requests through 400 address changes run: make -j"$(nproc)" http3-migration-e2e CWIST_WEBRTC=0 PYTHON="$RUNNER_TEMP/h3-migration/bin/python" + + redis-rate-limit: + name: Shared Redis rate limit (two processes) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install build dependencies and isolated Redis + run: | + sudo apt-get update + sudo apt-get install -y cmake libcurl4-openssl-dev libnghttp2-dev libbrotli-dev libzstd-dev redis-server + + - name: Test shared window, failures and bounded Redis calls + run: | + make -j"$(nproc)" SANITIZE=address,undefined WERROR=1 CWIST_HTTP3=0 CWIST_WEBRTC=0 redis-rate-limit-e2e test_redis_timeout test_redis_frame + env: + ASAN_OPTIONS: detect_leaks=1:abort_on_error=1:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:abort_on_error=1 diff --git a/.gitignore b/.gitignore index 7d6a57b19..055e2bd71 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,9 @@ test_middleware_ctx test_builtin_mw_chain test_proto_desc test_rate_limit +/test_rate_limit_redis +/test_redis_timeout +/test_redis_frame test_redis test_scheduler test_secure_headers diff --git a/Makefile b/Makefile index f3903c91f..26bdadb6a 100644 --- a/Makefile +++ b/Makefile @@ -386,6 +386,7 @@ SRCS = src/core/sstring/sstring.c \ src/core/utils/json_heal.c \ src/core/utils/zod.c \ src/sys/app/middleware.c \ + src/sys/app/rate_limit_redis.c \ src/sys/app/otel.c \ src/sys/app/config.c \ src/sys/app/logger.c \ @@ -1340,6 +1341,9 @@ TEST_TARGETS = test_worker_affinity \ test_metrics \ test_access_log \ test_rate_limit \ + test_rate_limit_redis \ + test_redis_timeout \ + test_redis_frame \ test_cache \ test_bdr \ test_bdr_cache \ @@ -1908,6 +1912,25 @@ test_rate_limit: $(LIB_NAME) tests/test_rate_limit.c $(CC) $(CFLAGS) -o test_rate_limit tests/test_rate_limit.c $(LIB_NAME) $(LIBS) ./test_rate_limit +test_rate_limit_redis: $(LIB_NAME) tests/test_rate_limit_redis.c + $(CC) $(CFLAGS) -o $@ tests/test_rate_limit_redis.c $(LIB_NAME) $(LIBS) + ./$@ + +test_redis_timeout: $(LIB_NAME) tests/test_redis_timeout.c + $(CC) $(CFLAGS) -o $@ tests/test_redis_timeout.c $(LIB_NAME) $(LIBS) + ./$@ + +src/net/redis/cwist_redis.o: src/net/redis/redis_frame_internal.h + +test_redis_frame: tests/test_redis_frame.c src/net/redis/redis_frame_internal.h + $(CC) $(CFLAGS) -o $@ tests/test_redis_frame.c + ./$@ + +.PHONY: redis-rate-limit-e2e +redis-rate-limit-e2e: $(LIB_NAME) tests/test_rate_limit_redis.c + $(CC) $(CFLAGS) -o test_rate_limit_redis tests/test_rate_limit_redis.c $(LIB_NAME) $(LIBS) + $(PYTHON) scripts/ci/redis_rate_limit_e2e.py ./test_rate_limit_redis + test_cache: $(LIB_NAME) tests/test_cache.c $(CC) $(CFLAGS) -o test_cache tests/test_cache.c $(LIB_NAME) $(LIBS) ./test_cache diff --git a/docs/api/middleware.md b/docs/api/middleware.md index 824f968d7..2173deb06 100644 --- a/docs/api/middleware.md +++ b/docs/api/middleware.md @@ -53,6 +53,84 @@ Limits the number of requests per minute from a single IP. cwist_app_use(app, cwist_mw_rate_limit_ip(60)); ``` +### Redis-backed shared rate limiter + +The local limiter above keeps its buckets in one process. To share one +limit across independent server processes, give each process its own +**dedicated pool on DB 0, with the default user and no AUTH**, and use the +same prefix and limit. Replacement connections start fresh: AUTH, SELECT, +HELLO and other connection state are not replayed. Legacy calls that share +this pool may also receive fresh connections after a timed call discards one. +Use one prefix per rate policy: different limits with the same prefix +share one counter, not independent buckets. + +```c +cwist_redis_pool_t *pool = cwist_redis_pool_create("127.0.0.1", 6379, 4); +cwist_rate_limit_redis_cfg_t cfg = { + .pool = pool, + .requests_per_minute = 60, + .key_prefix = "my-service:rate:", + .timeout_ms = 50, + .fail_closed = true, +}; +cwist_middleware_func limit = cwist_mw_rate_limit_ip_redis(&cfg); +/* Inside main: fail startup if registration did not succeed. */ +if (!pool || !limit) return 1; +cwist_app_use(app, limit); +/* One process per instance: do not fork a live pool. */ +cwist_app_listen_ex(app, 8080, 1, -1); +cwist_redis_pool_destroy(pool); +``` + +Each key is `prefix + peer-IP`. One Lua EVAL increments the counter and +sets a 60-second expiry on the first request. It repairs missing expiry +without extending a running window. The window starts at the first hit, +not a process-local clock boundary. Allowed and rejected attempts count. +Over-limit replies are 429 with a conservative `Retry-After: 60`. + +With `fail_closed = false` (default), backend errors allow the request. +With `true`, errors return 503, not 429. This includes timeout, pool +exhaustion, malformed replies, and a missing socket peer. A failed command +is never retried: it might already have incremented the counter. There is +no local fallback counter. + +The request-time budget covers pool wait, cached-peer reconnect, write, +and reply I/O. Zero selects 50 ms; the maximum is 60000 ms. This is a +blocking middleware, not an async reactor call. Scheduling delays are not +a real-time guarantee. Initial pool creation and DNS are outside this +budget. The first backend failure per config and process emits a warning +with the error code and policy to stderr. Keys and credentials are not logged. +The Redis command budget does not bound application logging. +Failed initial pool creation or invalid config returns NULL. + +Create pools after fork, separately in each serving process. Inherited +pools are rejected by the timed API. Keep the pool alive until every call +has stopped, then destroy it. Do not register a pre-fork pool for automatic +`CWIST_WORKERS` children; this API does not add a worker-start hook. Eight +distinct immutable configs fit per process; equal configs reuse a slot. +Prefixes are copied and limited to 128 bytes. + +Only the socket peer is used. Forwarded headers cannot choose the key. +A request without a connected TCP peer fd (for example, a QUIC request) +follows the missing-peer failure policy; this API does not extract QUIC +path metadata. IPv6 keys use the full address (/128), not a /64 prefix; +clients that rotate IPv6 addresses obtain separate buckets. +Behind a reverse proxy, this limits the proxy address unless a trusted +upstream preserves client source addresses. Redis RESP2 is plaintext: use +loopback, a private network, or a separate TLS tunnel. The default user's +ACL must allow EVAL, INCR, PTTL, and PEXPIRE. A server requiring AUTH makes +every call fail; the configured failure policy then applies to every request. +Eviction, failover, or external deletion +can reset counters; this is not a global consistency guarantee across +Redis data loss. + +Run `make redis-rate-limit-e2e` with redis-server installed. The runner +starts its own loopback Redis and never silently skips. Two C processes +create separate pools and invoke the middleware with real TCP peer fds. +Their 40 attempts share seven admissions. The test also checks TTL, +expiry, namespaces, and failure policies. The normal unit target skips +only the live-Redis part when no test port is supplied. + ### CORS Middleware Enables Cross-Origin Resource Sharing (CORS) support. - Adds `Access-Control-Allow-Origin: *` to all responses. diff --git a/docs/api/v4.0-api-baseline.txt b/docs/api/v4.0-api-baseline.txt index d1b1bf4b3..8ac60ff82 100644 --- a/docs/api/v4.0-api-baseline.txt +++ b/docs/api/v4.0-api-baseline.txt @@ -4,7 +4,7 @@ # (parameter names dropped) and struct/union/enum layouts in member order. # `make api-check` (CI) fails when the headers no longer match this file. -== functions (787) == +== functions (789) == cwist_accept_socket cwist_alloc cwist_alloc_array @@ -544,6 +544,7 @@ cwist_mw_metrics cwist_mw_otel_get_trace_id cwist_mw_otel_trace cwist_mw_rate_limit_ip +cwist_mw_rate_limit_ip_redis cwist_mw_rate_limit_reset cwist_mw_request_id cwist_mw_session @@ -651,6 +652,7 @@ cwist_redis_del cwist_redis_expire cwist_redis_get cwist_redis_pool_command_argv +cwist_redis_pool_command_argv_reply_timeout cwist_redis_pool_create cwist_redis_pool_del cwist_redis_pool_destroy @@ -793,7 +795,7 @@ cwist_zod_parse cwist_zod_print_errors cwist_zod_validate -== function signatures (788) == +== function signatures (790) == cwist_accept_socket: cwist_error_t (int, struct sockaddr *, void (*)(int client_fd, void *ctx), void *) cwist_alloc: void * (size_t) cwist_alloc_array: void * (size_t, size_t) @@ -1334,6 +1336,7 @@ cwist_mw_metrics: cwist_middleware_func (void) cwist_mw_otel_get_trace_id: const char * (const cwist_http_request *) cwist_mw_otel_trace: cwist_middleware_func (const cwist_otel_cfg_t *) cwist_mw_rate_limit_ip: cwist_middleware_func (int) +cwist_mw_rate_limit_ip_redis: cwist_middleware_func (const cwist_rate_limit_redis_cfg_t *) cwist_mw_rate_limit_reset: void (void) cwist_mw_request_id: cwist_middleware_func (const char *) cwist_mw_session: cwist_middleware_func (cwist_app *) @@ -1441,6 +1444,7 @@ cwist_redis_del: cwist_error_t (cwist_redis_t *, const char *) cwist_redis_expire: cwist_error_t (cwist_redis_t *, const char *, int) cwist_redis_get: cwist_error_t (cwist_redis_t *, const char *, char **) cwist_redis_pool_command_argv: cwist_error_t (cwist_redis_pool_t *, size_t, const void *const *, const size_t *, char **, size_t *) +cwist_redis_pool_command_argv_reply_timeout: cwist_error_t (cwist_redis_pool_t *, size_t, const void *const *, const size_t *, unsigned timeout_ms, cwist_redis_reply_t **) cwist_redis_pool_create: cwist_redis_pool_t * (const char *, int, size_t) cwist_redis_pool_del: cwist_error_t (cwist_redis_pool_t *, const char *) cwist_redis_pool_destroy: void (cwist_redis_pool_t *) @@ -1764,7 +1768,7 @@ cwist_zod_result quic_conn_fc quic_stream_fc -== typedefs (209) == +== typedefs (210) == __prim_cwist_error_t bdr_blob_t bdr_entry_t @@ -1915,6 +1919,7 @@ cwist_pb_writer cwist_query_bucket cwist_query_map cwist_query_map_iter_func +cwist_rate_limit_redis_cfg_t cwist_rdbms_provider_t cwist_rdbms_runtime cwist_reactor_cb_t @@ -2208,7 +2213,7 @@ CWIST_WS_FRAME_TEXT == globals (extern) (0) == -== layouts (83) == +== layouts (84) == enum cwist_async_action_t [0] CWIST_ASYNC_CLOSE = 0 enum cwist_async_action_t [1] CWIST_ASYNC_REARM enum cwist_async_action_t [2] CWIST_ASYNC_DETACH @@ -2715,6 +2720,11 @@ struct cwist_query_map [1] size_t size struct cwist_query_map [2] uint8_t seed[16] struct cwist_query_map [3] void *arena struct cwist_query_map [4] size_t count +struct cwist_rate_limit_redis_cfg_t [0] cwist_redis_pool_t *pool +struct cwist_rate_limit_redis_cfg_t [1] int requests_per_minute +struct cwist_rate_limit_redis_cfg_t [2] const char *key_prefix +struct cwist_rate_limit_redis_cfg_t [3] unsigned timeout_ms +struct cwist_rate_limit_redis_cfg_t [4] bool fail_closed struct cwist_rdbms_runtime [0] cwist_rdbms_provider_t provider struct cwist_rdbms_runtime [1] int port struct cwist_rdbms_runtime [2] char *host diff --git a/include/cwist/net/redis/cwist_redis.h b/include/cwist/net/redis/cwist_redis.h index 54dd6fe77..1fb592e86 100644 --- a/include/cwist/net/redis/cwist_redis.h +++ b/include/cwist/net/redis/cwist_redis.h @@ -154,6 +154,58 @@ cwist_error_t cwist_redis_pool_command_argv(cwist_redis_pool_t *pool, size_t arg char **out, size_t *out_len); /** @} */ +/** + * @brief Run one binary-safe pooled command under a single deadline. + * + * One CLOCK_MONOTONIC budget of @p timeout_ms covers + * waiting for a free pool connection, reconnecting a discarded connection, + * writing the request, and reading the complete reply. The pool's legacy + * blocking APIs keep their unbounded behavior; pool creation still performs + * blocking DNS and connects. + * + * Connection safety: an idle connection with buffered or readable bytes, a + * hang-up, or a previous failure is discarded before use and replaced by a + * new connection to the peer address resolved by cwist_redis_pool_create() + * (no DNS at request time). Once any request byte has been written the + * command is never retried; on timeout, transport error, or malformed reply + * the connection is closed so a late reply can never be read by a later + * caller. SIGPIPE is not raised. Reply parsing is strict RESP2 with limits + * (8 nesting levels, 65536 nodes, 8 MiB per bulk string, 16 MiB of payload, + * 8 KiB status/header lines). + * + * Replacement connections start fresh: AUTH, SELECT, HELLO and other + * per-connection state are not replayed. Pools used with this API must not + * rely on such state. Legacy pooled calls may also receive a fresh connection + * after a timed call discards a socket. Use a dedicated default-state pool. + * + * Reply types follow cwist_redis_reply_t. A null bulk string is '$' with + * @c str == NULL and @c integer == -1; a null array is '*' with + * @c elements == 0 and @c integer == -1. Nested '-' elements are data. + * + * The pool must have been created by the calling process (a pool inherited + * across fork() is rejected without touching it) and must not be destroyed + * concurrently with this call. + * + * @param pool Pool from cwist_redis_pool_create(). + * @param argc Number of arguments (1 .. 1048576). + * @param argv Argument pointers; an entry may be NULL only if its length is 0. + * @param argv_lens Byte length of each argument (frame limited to 512 MiB). + * @param timeout_ms Total budget in milliseconds; must be positive. + * @param reply Out: reply tree on success (free with cwist_redis_reply_free()); + * always set to NULL on failure. + * @return CWIST_ERR_INT16 error whose @c err_i16 is 0 on success or a negative + * errno: -EINVAL invalid argument or timeout 0; -EPERM pool inherited + * from another process; -ETIMEDOUT budget exhausted; -EIO connect, + * send, or receive failure, or peer EOF; -EPROTO malformed or + * over-limit reply; -EBADMSG top-level Redis error reply (the + * connection stays in sync and reusable); -ENOMEM allocation failure. + */ +cwist_error_t cwist_redis_pool_command_argv_reply_timeout(cwist_redis_pool_t *pool, size_t argc, + const void *const *argv, + const size_t *argv_lens, + unsigned timeout_ms, + cwist_redis_reply_t **reply); + #ifdef __cplusplus } #endif diff --git a/include/cwist/sys/app/middleware.h b/include/cwist/sys/app/middleware.h index 68d5e7ccd..d7392b5c2 100644 --- a/include/cwist/sys/app/middleware.h +++ b/include/cwist/sys/app/middleware.h @@ -9,6 +9,8 @@ #include #include #include +#include +#include /** * @brief Request ID middleware. @@ -25,7 +27,36 @@ cwist_middleware_func cwist_mw_access_log(cwist_log_format_t format); /** @brief Fixed-window rate limiter middleware (per-IP). */ cwist_middleware_func cwist_mw_rate_limit_ip(int requests_per_minute); -/** @brief Reset the internal rate-limit IP bucket table (test helper). */ +/** @brief Redis-backed rate limiter configuration. Strings are copied. */ +typedef struct { + cwist_redis_pool_t *pool; /**< Borrowed; must outlive all middleware calls. */ + int requests_per_minute; /**< Positive shared limit per IP and window. */ + const char *key_prefix; /**< Default "cwist:rl:"; at most 128 bytes. */ + unsigned timeout_ms; /**< Total Redis I/O budget; zero selects 50 ms, max 60000. */ + bool fail_closed; /**< False: allow on backend error; true: return 503. */ +} cwist_rate_limit_redis_cfg_t; + +/** + * @brief Shared per-IP 60-second window using one atomic Redis Lua command. + * + * Uses the socket peer, never forwarded headers. A rejected request returns + * 429 with Retry-After: 60. Backend errors and missing peer addresses follow + * fail_closed. The first backend failure per configuration in each process + * writes its error code and policy to stderr (no keys or credentials). + * No local fallback counter is used. Requests do not extend TTL. + * Use a dedicated pool on DB 0, with the default user and no AUTH. + * + * Create the pool separately in every serving process after fork. + * Pool creation and DNS are blocking; timeout_ms bounds request-time pool wait and I/O. + * Use cwist_app_listen_ex() with one worker or CWIST_WORKERS=1, so the + * application listener does not fork the initialized pool. + * Do not destroy the pool while calls may still run. Eight distinct immutable + * configurations are supported per process; equal configurations reuse a slot. + * @return Middleware, or NULL for invalid configuration or exhausted slots. + */ +cwist_middleware_func cwist_mw_rate_limit_ip_redis(const cwist_rate_limit_redis_cfg_t *cfg); + +/** @brief Reset the process-local IP table; Redis configs and keys are unchanged. */ void cwist_mw_rate_limit_reset(void); /** @brief Prometheus metrics collection middleware. */ diff --git a/scripts/ci/api-untested-baseline.txt b/scripts/ci/api-untested-baseline.txt index 257050fa7..0f1426ca3 100644 --- a/scripts/ci/api-untested-baseline.txt +++ b/scripts/ci/api-untested-baseline.txt @@ -169,11 +169,9 @@ cwist_redis_auth cwist_redis_command cwist_redis_command_argv_reply cwist_redis_expire -cwist_redis_pool_command_argv cwist_redis_pool_publish cwist_redis_pool_setex cwist_redis_publish -cwist_redis_reply_free cwist_redis_select cwist_redis_subscribe cwist_reg_ptr diff --git a/scripts/ci/redis_rate_limit_e2e.py b/scripts/ci/redis_rate_limit_e2e.py new file mode 100755 index 000000000..a9f9f09db --- /dev/null +++ b/scripts/ci/redis_rate_limit_e2e.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Run the distributed limiter checks against a fresh loopback Redis. + +No connection is made to an existing Redis. A missing redis-server or a +failed startup is an error, never a skipped integration check. +""" + +import argparse +import os +import shutil +import signal +import socket +import subprocess +import tempfile +import time +from pathlib import Path + + +def ready(port, process): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("owned Redis exited during startup") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2) as sock: + sock.sendall(b"*1\r\n$4\r\nPING\r\n") + reply = b"" + while not reply.endswith(b"\r\n") and len(reply) < 64: + chunk = sock.recv(64) + if not chunk: + break + reply += chunk + if reply == b"+PONG\r\n": + return + except OSError: + pass + time.sleep(0.02) + raise TimeoutError("owned Redis did not become ready in 10 seconds") + + +def run(binary, redis_server): + executable = shutil.which(redis_server) + if not executable: + raise RuntimeError("redis-server is required for this integration test") + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + parent = os.environ.get("RUNNER_TEMP") or os.environ.get("TMPDIR") or "." + with tempfile.TemporaryDirectory( + prefix="cwist-redis-rate-", dir=parent + ) as directory: + log = Path(directory) / "redis.log" + redis = subprocess.Popen( + [ + executable, + "--bind", + "127.0.0.1", + "--port", + str(port), + "--protected-mode", + "yes", + "--save", + "", + "--appendonly", + "no", + "--daemonize", + "no", + "--dir", + directory, + "--logfile", + str(log), + ], + stdout=subprocess.DEVNULL, + ) + test = None + try: + ready(port, redis) + env = dict(os.environ) + env["CWIST_REDIS_RATE_LIMIT_TEST_PORT"] = str(port) + env["CWIST_REDIS_RATE_LIMIT_REQUIRE"] = "1" + test = subprocess.Popen( + [str(Path(binary).resolve())], env=env, start_new_session=True + ) + try: + code = test.wait(timeout=30) + except subprocess.TimeoutExpired: + os.killpg(test.pid, signal.SIGKILL) + test.wait() + raise TimeoutError("limiter test exceeded 30 seconds") from None + if code: + raise RuntimeError(f"limiter test failed with exit {code}") + print("[PASS] strict owned-Redis two-process integration completed") + except BaseException: + if test is not None: + # The test may have forked workers before its parent failed. + try: + os.killpg(test.pid, signal.SIGKILL) + except ProcessLookupError: + pass + test.wait() + if log.exists(): + print(log.read_text()[-2000:]) + raise + finally: + redis.terminate() + try: + redis.wait(timeout=5) + except subprocess.TimeoutExpired: + redis.kill() + redis.wait() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary") + parser.add_argument("--redis-server", default="redis-server") + args = parser.parse_args() + run(args.binary, args.redis_server) + + +if __name__ == "__main__": + main() diff --git a/src/net/redis/cwist_redis.c b/src/net/redis/cwist_redis.c index 409658334..28bbf8929 100644 --- a/src/net/redis/cwist_redis.c +++ b/src/net/redis/cwist_redis.c @@ -4,16 +4,23 @@ */ #define _POSIX_C_SOURCE 200809L #include +#include "redis_frame_internal.h" #include #include #include #include #include #include +#include #include +#include +#include #include #include +#include #include +#include +#include #include #include #include @@ -29,6 +36,23 @@ #define CWIST_REDIS_MAX_REPLY_DEPTH 16 #define CWIST_REDIS_MAX_REPLY_ELEMENTS (1 << 20) +/* Limits for the deadline-bounded pooled command. Tighter than the legacy + * parser: a timed caller (e.g. a per-request rate limiter) never needs huge + * replies, and every byte must arrive inside the caller's budget anyway. */ +#define CWIST_REDIS_TIMED_MAX_ARGC ((size_t)1 << 20) +#define CWIST_REDIS_TIMED_MAX_FRAME ((size_t)512 * 1024 * 1024) +#define CWIST_REDIS_TIMED_MAX_DEPTH 8 +#define CWIST_REDIS_TIMED_MAX_NODES ((size_t)65536) +#define CWIST_REDIS_TIMED_MAX_BULK ((size_t)8 * 1024 * 1024) +#define CWIST_REDIS_TIMED_MAX_PAYLOAD ((size_t)16 * 1024 * 1024) + +/* Timed sends never raise SIGPIPE (Apple sockets also get SO_NOSIGPIPE). */ +#ifdef MSG_NOSIGNAL +#define CWIST_REDIS_NOSIGNAL MSG_NOSIGNAL +#else +#define CWIST_REDIS_NOSIGNAL 0 +#endif + struct cwist_redis { int fd; char *host; @@ -46,6 +70,21 @@ struct cwist_redis_pool { bool *available; pthread_mutex_t mtx; pthread_cond_t cond; + /* Process that created the pool; timed commands from any other process + * (a fork() child) are rejected because the sockets are shared. */ + pid_t owner_pid; + /* Peer address resolved at creation, reused for reconnects so request + * paths never perform DNS. peer_len == 0 means unknown (no reconnect). */ + struct sockaddr_storage peer; + socklen_t peer_len; + /* Wake pipe for timed acquirers: they poll() the read end because + * condition-variable and nanosleep timeouts can be coalesced by tens of + * milliseconds (observed ~70-150 ms on background-QoS macOS) while poll() + * timeouts stay precise. Releases write one byte while timed_waiters > 0. + * -1 when unavailable; timed waits then fall back to the condition. */ + int wake_rd; + int wake_wr; + size_t timed_waiters; }; /* --- Socket helpers ----------------------------------------------------- */ @@ -159,6 +198,222 @@ static int redis_recv_bytes(cwist_redis_t *r, char *out, size_t len) { return 0; } +/* --- Deadline-bounded socket helpers ------------------------------------ */ + +/** Current CLOCK_MONOTONIC time in nanoseconds. */ +static uint64_t redis_now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000u + (uint64_t)ts.tv_nsec; +} + +/** Milliseconds left before @p deadline, rounded up and clamped to INT_MAX; + * 0 once the deadline has passed. */ +static int redis_remaining_ms(uint64_t deadline) { + uint64_t now = redis_now_ns(); + if (now >= deadline) return 0; + uint64_t ms = (deadline - now + 999999u) / 1000000u; + return ms > (uint64_t)INT_MAX ? INT_MAX : (int)ms; +} + +/** Build a CWIST_ERR_INT16 error carrying a (negative errno) code. */ +static cwist_error_t redis_code_error(int code) { + cwist_error_t err = make_error(CWIST_ERR_INT16); + err.error.err_i16 = (int16_t)code; + return err; +} + +/** Wait until @p fd reports @p events (or an error/hang-up, which the next + * I/O call surfaces) without passing @p deadline. + * @return 0 when ready, -ETIMEDOUT, or -EIO when poll() itself fails. */ +static int redis_wait_fd(int fd, short events, uint64_t deadline) { + for (;;) { + int ms = redis_remaining_ms(deadline); + if (ms <= 0) return -ETIMEDOUT; + struct pollfd p = {.fd = fd, .events = events, .revents = 0}; + int n = poll(&p, 1, ms); + if (n > 0) return 0; + if (n < 0 && errno != EINTR) return -EIO; + } +} + +/** Write a whole frame before @p deadline without SIGPIPE. @p fd must be in + * O_NONBLOCK mode: MSG_DONTWAIT is not honoured by send() on every platform + * (XNU ignores it and blocks on a full send buffer). + * @return 0, -ETIMEDOUT, or -EIO. A non-zero result may leave a partial + * frame on the wire, so the caller must discard the connection. */ +static int redis_send_deadline(int fd, const char *buf, size_t len, uint64_t deadline) { + size_t sent = 0; + while (sent < len) { + if (redis_now_ns() >= deadline) return -ETIMEDOUT; + ssize_t n = send(fd, buf + sent, len - sent, CWIST_REDIS_NOSIGNAL); + if (n > 0) { + sent += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOBUFS)) { + int rc = redis_wait_fd(fd, POLLOUT, deadline); + if (rc != 0) return rc; + continue; + } + return -EIO; + } + return 0; +} + +/** Append at least one byte to the receive buffer before @p deadline. + * r->fd must be in O_NONBLOCK mode. + * @return 0, -ETIMEDOUT, -EIO (error or peer EOF), or -EPROTO (buffer full). */ +static int redis_fill_deadline(cwist_redis_t *r, uint64_t deadline) { + if (r->recv_len >= CWIST_REDIS_BUF_SIZE) return -EPROTO; + for (;;) { + if (redis_now_ns() >= deadline) return -ETIMEDOUT; + ssize_t n = recv(r->fd, r->recv_buf + r->recv_len, CWIST_REDIS_BUF_SIZE - r->recv_len, 0); + if (n > 0) { + r->recv_len += (size_t)n; + return 0; + } + if (n == 0) return -EIO; + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + int rc = redis_wait_fd(r->fd, POLLIN, deadline); + if (rc != 0) return rc; + continue; + } + return -EIO; + } +} + +/** Drop @p n consumed bytes from the front of the receive buffer. */ +static void redis_consume(cwist_redis_t *r, size_t n) { + memmove(r->recv_buf, r->recv_buf + n, r->recv_len - n); + r->recv_len -= n; +} + +/** Ensure one complete CRLF line sits at the front of the receive buffer. + * On success *@p out_len is the line length without CRLF; the caller reads + * r->recv_buf[0..len) and then consumes len + 2 bytes. Lines longer than + * CWIST_REDIS_LINE_MAX - 1 bytes, bare LF terminators, and stray CR bytes + * are protocol errors. */ +static int redis_peek_line(cwist_redis_t *r, uint64_t deadline, size_t *out_len) { + size_t scanned = 0; + for (;;) { + char *nl = memchr(r->recv_buf + scanned, '\n', r->recv_len - scanned); + if (nl) { + size_t end = (size_t)(nl - r->recv_buf); + if (end == 0 || r->recv_buf[end - 1] != '\r') return -EPROTO; + size_t len = end - 1; + if (len >= CWIST_REDIS_LINE_MAX) return -EPROTO; + if (memchr(r->recv_buf, '\r', len)) return -EPROTO; + *out_len = len; + return 0; + } + scanned = r->recv_len; + if (r->recv_len > CWIST_REDIS_LINE_MAX) return -EPROTO; + int rc = redis_fill_deadline(r, deadline); + if (rc != 0) return rc; + } +} + +/** Copy exactly @p len payload bytes into @p out before @p deadline. */ +static int redis_read_exact(cwist_redis_t *r, char *out, size_t len, uint64_t deadline) { + size_t got = 0; + while (got < len) { + if (r->recv_len == 0) { + int rc = redis_fill_deadline(r, deadline); + if (rc != 0) return rc; + } + size_t take = r->recv_len < len - got ? r->recv_len : len - got; + memcpy(out + got, r->recv_buf, take); + redis_consume(r, take); + got += take; + } + return 0; +} + +/** Strict RESP integer: optional '-', then 1+ decimal digits, no overflow. */ +static bool redis_parse_ll(const char *s, size_t n, long long *out) { + size_t i = 0; + bool neg = n > 0 && s[0] == '-'; + if (neg) i = 1; + if (i >= n) return false; + unsigned long long limit = + neg ? (unsigned long long)LLONG_MAX + 1u : (unsigned long long)LLONG_MAX; + unsigned long long v = 0; + for (; i < n; i++) { + if (s[i] < '0' || s[i] > '9') return false; + unsigned d = (unsigned)(s[i] - '0'); + if (v > (limit - d) / 10u) return false; + v = v * 10u + d; + } + if (!neg) + *out = (long long)v; + else if (v == (unsigned long long)LLONG_MAX + 1u) + *out = LLONG_MIN; + else + *out = -(long long)v; + return true; +} + +/** Close a pooled connection's socket so no later caller can read a stale or + * partial reply from it. The handle stays in its slot with fd == -1 and is + * reconnected (to the cached peer) by the next user. */ +static void redis_conn_discard(cwist_redis_t *r) { + if (r->fd >= 0) { + shutdown(r->fd, SHUT_RDWR); + close(r->fd); + } + r->fd = -1; + r->recv_len = 0; +} + +/** True when an idle connection is not in a clean request state: bytes are + * pending (an unsolicited/late reply) or the peer hung up/errored. */ +static bool redis_conn_unclean(const cwist_redis_t *r) { + if (r->recv_len != 0) return true; + struct pollfd p = {.fd = r->fd, .events = POLLIN, .revents = 0}; + int n; + do { + n = poll(&p, 1, 0); + } while (n < 0 && errno == EINTR); + return n != 0; +} + +/** Open a new socket to the pool's cached peer address (no DNS), bounded by + * @p deadline, and install it in @p r. The socket is left in blocking mode + * so the legacy APIs keep working on it. + * @return 0, -ETIMEDOUT, or -EIO (no cached peer, socket/connect failure). */ +static int redis_reconnect(const cwist_redis_pool_t *pool, cwist_redis_t *r, uint64_t deadline) { + if (pool->peer_len == 0) return -EIO; + int fd = socket(pool->peer.ss_family, SOCK_STREAM, 0); + if (fd < 0) return -EIO; + int rc = -EIO; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); +#ifdef SO_NOSIGPIPE + int one = 1; + (void)setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); +#endif + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) goto out; + if (connect(fd, (const struct sockaddr *)&pool->peer, pool->peer_len) != 0) { + if (errno != EINPROGRESS && errno != EINTR) goto out; + rc = redis_wait_fd(fd, POLLOUT, deadline); + if (rc != 0) goto out; + rc = -EIO; + int soerr = 0; + socklen_t sl = sizeof(soerr); + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &sl) != 0 || soerr != 0) goto out; + } + if (fcntl(fd, F_SETFL, flags) != 0) goto out; + r->fd = fd; + r->recv_len = 0; + return 0; +out: + close(fd); + return rc; +} + /* --- RESP2 helpers ------------------------------------------------------ */ void cwist_redis_reply_free(cwist_redis_reply_t *reply); @@ -784,6 +1039,8 @@ cwist_redis_pool_t *cwist_redis_pool_create(const char *host, int port, size_t m cwist_redis_pool_t *pool = cwist_alloc(sizeof(*pool)); if (!pool) return NULL; + pool->wake_rd = -1; + pool->wake_wr = -1; pool->host = cwist_strdup(host); pool->port = port; pool->max_conns = max_conns; @@ -797,6 +1054,29 @@ cwist_redis_pool_t *cwist_redis_pool_create(const char *host, int port, size_t m pool->available[i] = true; } + /* Cache the resolved peer once so request-time reconnects need no DNS. */ + pool->peer_len = (socklen_t)sizeof(pool->peer); + if (getpeername(pool->conns[0]->fd, (struct sockaddr *)&pool->peer, &pool->peer_len) != 0) + pool->peer_len = 0; + pool->owner_pid = getpid(); + + int wake[2]; + if (pipe(wake) == 0) { + bool ok = true; + for (int i = 0; i < 2; i++) { + int fl = fcntl(wake[i], F_GETFL, 0); + ok = ok && fl >= 0 && fcntl(wake[i], F_SETFL, fl | O_NONBLOCK) == 0 && + fcntl(wake[i], F_SETFD, FD_CLOEXEC) == 0; + } + if (ok) { + pool->wake_rd = wake[0]; + pool->wake_wr = wake[1]; + } else { + close(wake[0]); + close(wake[1]); + } + } + pthread_mutex_init(&pool->mtx, NULL); pthread_cond_init(&pool->cond, NULL); return pool; @@ -806,6 +1086,8 @@ cwist_redis_pool_t *cwist_redis_pool_create(const char *host, int port, size_t m for (size_t i = 0; i < max_conns && pool->conns; i++) { if (pool->conns[i]) cwist_redis_close(pool->conns[i]); } + if (pool->wake_rd >= 0) close(pool->wake_rd); + if (pool->wake_wr >= 0) close(pool->wake_wr); cwist_free(pool->conns); cwist_free(pool->available); cwist_free(pool->host); @@ -822,6 +1104,8 @@ void cwist_redis_pool_destroy(cwist_redis_pool_t *pool) { if (pool->conns[i]) cwist_redis_close(pool->conns[i]); } pthread_mutex_unlock(&pool->mtx); + if (pool->wake_rd >= 0) close(pool->wake_rd); + if (pool->wake_wr >= 0) close(pool->wake_wr); pthread_mutex_destroy(&pool->mtx); pthread_cond_destroy(&pool->cond); cwist_free(pool->conns); @@ -831,7 +1115,9 @@ void cwist_redis_pool_destroy(cwist_redis_pool_t *pool) { } /** Acquire an idle pool connection, blocking on the condition variable until - * one becomes available. + * one becomes available. A slot whose socket was discarded by the timed API + * is reconnected (blocking, to the cached peer) before it is handed out; if + * that fails the command fails on the closed handle as before. * @return A connection marked in-use; NULL only when @p pool is NULL. */ static cwist_redis_t *redis_pool_acquire(cwist_redis_pool_t *pool) { if (!pool) return NULL; @@ -841,14 +1127,17 @@ static cwist_redis_t *redis_pool_acquire(cwist_redis_pool_t *pool) { if (pool->available[i]) { pool->available[i] = false; pthread_mutex_unlock(&pool->mtx); - return pool->conns[i]; + cwist_redis_t *r = pool->conns[i]; + if (r->fd < 0) (void)redis_reconnect(pool, r, UINT64_MAX); + return r; } } pthread_cond_wait(&pool->cond, &pool->mtx); } } -/** Return a connection to the pool and signal a waiting acquirer. */ +/** Return a connection to the pool and signal a waiting acquirer (one + * condition waiter plus, when present, the timed waiters' wake pipe). */ static void redis_pool_release(cwist_redis_pool_t *pool, cwist_redis_t *r) { if (!pool || !r) return; pthread_mutex_lock(&pool->mtx); @@ -856,6 +1145,12 @@ static void redis_pool_release(cwist_redis_pool_t *pool, cwist_redis_t *r) { if (pool->conns[i] == r) { pool->available[i] = true; pthread_cond_signal(&pool->cond); + if (pool->timed_waiters > 0 && pool->wake_wr >= 0) { + /* Non-blocking; a full pipe is already readable. */ + const char b = 1; + ssize_t n = write(pool->wake_wr, &b, 1); + (void)n; + } break; } } @@ -927,3 +1222,298 @@ cwist_error_t cwist_redis_pool_command_argv(cwist_redis_pool_t *pool, size_t arg redis_pool_release(pool, r); return err; } + +/* --- Deadline-bounded pooled command ------------------------------------ */ + +/** Wait for a release for at most the time left before @p deadline. + * Caller holds pool->mtx (dropped while sleeping). Wakeups (releases, + * timeouts, spurious) are all handled by the caller rescanning the slots and + * re-checking the deadline. + * + * Preferred path: poll() the wake pipe. A release that lands between the + * unlock and poll() leaves a byte in the pipe, so it is never lost. Each + * wakeup consumes at most one byte so concurrent timed waiters cannot steal + * each other's notifications; the last waiter out drains leftovers (safe + * because no waiter can be inside poll() while the count is zero). */ +static void redis_pool_wait_until(cwist_redis_pool_t *pool, uint64_t deadline) { + uint64_t now = redis_now_ns(); + if (now >= deadline) return; + uint64_t rem = deadline - now; + if (pool->wake_rd >= 0) { + uint64_t ms = (rem + 999999u) / 1000000u; + struct pollfd pfd = {.fd = pool->wake_rd, .events = POLLIN, .revents = 0}; + pool->timed_waiters++; + pthread_mutex_unlock(&pool->mtx); + (void)poll(&pfd, 1, ms > (uint64_t)INT_MAX ? INT_MAX : (int)ms); + pthread_mutex_lock(&pool->mtx); + pool->timed_waiters--; + char sink[64]; + if (pool->timed_waiters == 0) { + while (read(pool->wake_rd, sink, sizeof(sink)) > 0) { + } + } else { + ssize_t n = read(pool->wake_rd, sink, 1); + (void)n; + } + return; + } + /* Pipe allocation failed: use short monotonic rechecks, not a + * realtime condition timer that can jump backwards or be coalesced. */ + int ms = redis_remaining_ms(deadline); + pthread_mutex_unlock(&pool->mtx); + if (ms > 0) (void)poll(NULL, 0, ms < 2 ? ms : 2); + pthread_mutex_lock(&pool->mtx); +} + +/** Claim a free slot before @p deadline, preferring one whose socket is + * still open. Slots are rescanned after every wakeup before the deadline is + * checked, so a release signal consumed here is never lost: either this + * caller takes the slot (and later releases it, signalling again) or no + * slot was free. + * @return 0 with *@p out set, or -ETIMEDOUT. */ +static int redis_pool_acquire_deadline(cwist_redis_pool_t *pool, uint64_t deadline, + cwist_redis_t **out) { + pthread_mutex_lock(&pool->mtx); + for (;;) { + size_t pick = pool->max_conns; + for (size_t i = 0; i < pool->max_conns; i++) { + if (!pool->available[i]) continue; + if (pick == pool->max_conns) pick = i; + if (pool->conns[i]->fd >= 0) { + pick = i; + break; + } + } + if (pick < pool->max_conns) { + pool->available[pick] = false; + pthread_mutex_unlock(&pool->mtx); + *out = pool->conns[pick]; + return 0; + } + if (redis_now_ns() >= deadline) { + pthread_mutex_unlock(&pool->mtx); + return -ETIMEDOUT; + } + redis_pool_wait_until(pool, deadline); + } +} + +/** Number of decimal digits in @p v. */ +static size_t redis_digits(size_t v) { + size_t n = 1; + while (v >= 10) { + v /= 10; + n++; + } + return n; +} + +/** Validate argv and compute the exact RESP frame size without touching any + * argument bytes. @return 0 or -EINVAL (bad pointer/length, size limits). */ +static int redis_frame_size(size_t argc, const void *const *argv, const size_t *argv_lens, + size_t *out) { + if (argc > CWIST_REDIS_TIMED_MAX_ARGC) return -EINVAL; + size_t total = 1 + redis_digits(argc) + 2; + for (size_t i = 0; i < argc; i++) { + size_t len = argv_lens[i]; + if (!argv[i] && len) return -EINVAL; + if (len > CWIST_REDIS_TIMED_MAX_FRAME) return -EINVAL; + size_t part = 1 + redis_digits(len) + 2 + len + 2; + if (part > CWIST_REDIS_TIMED_MAX_FRAME - total) return -EINVAL; + total += part; + } + *out = total; + return 0; +} + +typedef struct redis_timed_parse { + cwist_redis_t *r; + uint64_t deadline; + size_t nodes; + size_t payload; +} redis_timed_parse; + +/** Copy @p len bytes into a new NUL-terminated cwist_alloc() string. */ +static char *redis_copy_str(const char *src, size_t len) { + char *s = cwist_alloc(len + 1); + if (!s) return NULL; + if (len) memcpy(s, src, len); + s[len] = '\0'; + return s; +} + +/** Strict, limit-checked, deadline-bounded RESP2 parser. + * @return 0 with *@p out set, or a negative errno with *@p out NULL. Any + * failure leaves the stream position undefined (caller discards). */ +static int redis_timed_read(redis_timed_parse *ps, int depth, cwist_redis_reply_t **out) { + *out = NULL; + if (++ps->nodes > CWIST_REDIS_TIMED_MAX_NODES) return -EPROTO; + cwist_redis_t *r = ps->r; + size_t n = 0; + int rc = redis_peek_line(r, ps->deadline, &n); + if (rc != 0) return rc; + if (n == 0) return -EPROTO; + const char *line = r->recv_buf; + char type = line[0]; + long long v = 0; + if ((type == ':' || type == '$' || type == '*') && !redis_parse_ll(line + 1, n - 1, &v)) + return -EPROTO; + if (type != '+' && type != '-' && type != ':' && type != '$' && type != '*') return -EPROTO; + if (n - 1 > CWIST_REDIS_TIMED_MAX_PAYLOAD - ps->payload) return -EPROTO; + + cwist_redis_reply_t *node = cwist_alloc(sizeof(*node)); + if (!node) return -ENOMEM; + node->type = type; + rc = -EPROTO; + switch (type) { + case '+': + case '-': + case ':': + ps->payload += n - 1; + node->str = redis_copy_str(line + 1, n - 1); + if (!node->str) { + rc = -ENOMEM; + goto fail; + } + node->len = n - 1; + node->integer = type == ':' ? v : 0; + redis_consume(r, n + 2); + break; + case '$': { + redis_consume(r, n + 2); + if (v == -1) { + node->integer = -1; + break; + } + if (v < 0 || (unsigned long long)v > CWIST_REDIS_TIMED_MAX_BULK || + (size_t)v > CWIST_REDIS_TIMED_MAX_PAYLOAD - ps->payload) + goto fail; + ps->payload += (size_t)v; + node->str = cwist_alloc((size_t)v + 1); + if (!node->str) { + rc = -ENOMEM; + goto fail; + } + rc = redis_read_exact(r, node->str, (size_t)v, ps->deadline); + if (rc != 0) goto fail; + node->str[v] = '\0'; + node->len = (size_t)v; + node->integer = v; + char crlf[2]; + rc = redis_read_exact(r, crlf, 2, ps->deadline); + if (rc != 0) goto fail; + if (crlf[0] != '\r' || crlf[1] != '\n') { + rc = -EPROTO; + goto fail; + } + break; + } + case '*': + redis_consume(r, n + 2); + if (v == -1) { + node->integer = -1; + break; + } + if (v < 0 || depth >= CWIST_REDIS_TIMED_MAX_DEPTH || + (unsigned long long)v > CWIST_REDIS_TIMED_MAX_NODES - ps->nodes) + goto fail; + node->integer = v; + if (v > 0) { + node->element = cwist_alloc_array((size_t)v, sizeof(*node->element)); + if (!node->element) { + rc = -ENOMEM; + goto fail; + } + node->elements = (size_t)v; + for (size_t i = 0; i < (size_t)v; i++) { + node->element[i] = NULL; + rc = redis_timed_read(ps, depth + 1, &node->element[i]); + if (rc != 0) goto fail; + } + } + break; + } + *out = node; + return 0; +fail: + cwist_redis_reply_free(node); + return rc; +} + +/** Deadline-bounded pooled command; see cwist_redis.h for the contract. */ +cwist_error_t cwist_redis_pool_command_argv_reply_timeout(cwist_redis_pool_t *pool, size_t argc, + const void *const *argv, + const size_t *argv_lens, + unsigned timeout_ms, + cwist_redis_reply_t **reply) { + if (reply) *reply = NULL; + if (!pool || !argc || !argv || !argv_lens || !reply || timeout_ms == 0) + return redis_code_error(-EINVAL); + const uint64_t deadline = redis_now_ns() + (uint64_t)timeout_ms * 1000000u; + /* A fork() child shares the parent's sockets and may hold a copy of a + * locked mutex: reject before touching any pool state. */ + if (pool->owner_pid != getpid()) return redis_code_error(-EPERM); + + size_t frame_len = 0; + int rc = redis_frame_size(argc, argv, argv_lens, &frame_len); + if (rc != 0) return redis_code_error(rc); + char *frame = cwist_malloc(frame_len + 1); + if (!frame) return redis_code_error(-ENOMEM); + rc = redis_frame_fill(frame, frame_len + 1, argc, argv, argv_lens); + if (rc != 0) { + cwist_free(frame); + return redis_code_error(rc); + } + + cwist_redis_t *r = NULL; + rc = redis_pool_acquire_deadline(pool, deadline, &r); + if (rc != 0) { + cwist_free(frame); + return redis_code_error(rc); + } + + cwist_redis_reply_t *tree = NULL; + pthread_mutex_lock(&r->mtx); + if (redis_now_ns() >= deadline) { + rc = -ETIMEDOUT; + } else { + /* Nothing has been sent yet, so replacing a stale, hung-up, or + * previously discarded connection is not a retry. */ + if (r->fd >= 0 && redis_conn_unclean(r)) redis_conn_discard(r); + rc = r->fd >= 0 ? 0 : redis_reconnect(pool, r, deadline); + } + if (rc == 0) { +#if !defined(MSG_NOSIGNAL) && defined(SO_NOSIGPIPE) + /* No per-send flag: mark the (possibly legacy-created) socket. */ + int nosig = 1; + (void)setsockopt(r->fd, SOL_SOCKET, SO_NOSIGPIPE, &nosig, sizeof(nosig)); +#endif + /* Non-blocking only for this call; legacy APIs expect blocking fds. */ + int fl = fcntl(r->fd, F_GETFL, 0); + if (fl < 0 || fcntl(r->fd, F_SETFL, fl | O_NONBLOCK) != 0) rc = -EIO; + /* From the first written byte on, the command is ambiguous: any + * failure discards the connection and is never retried. */ + if (rc == 0) rc = redis_send_deadline(r->fd, frame, frame_len, deadline); + if (rc == 0) { + redis_timed_parse ps = {.r = r, .deadline = deadline, .nodes = 0, .payload = 0}; + rc = redis_timed_read(&ps, 0, &tree); + } + if (rc == 0 && fcntl(r->fd, F_SETFL, fl) != 0) rc = -EIO; + /* Bytes beyond the reply mean the peer is out of sync with us. */ + if (rc != 0 || r->recv_len != 0) redis_conn_discard(r); + if (rc == 0 && tree->type == '-') { + cwist_redis_reply_free(tree); + tree = NULL; + rc = -EBADMSG; + } + } + pthread_mutex_unlock(&r->mtx); + redis_pool_release(pool, r); + cwist_free(frame); + if (rc != 0) { + cwist_redis_reply_free(tree); + return redis_code_error(rc); + } + *reply = tree; + return redis_code_error(0); +} diff --git a/src/net/redis/redis_frame_internal.h b/src/net/redis/redis_frame_internal.h new file mode 100644 index 000000000..3e33c5f1e --- /dev/null +++ b/src/net/redis/redis_frame_internal.h @@ -0,0 +1,32 @@ +/* Private RESP encoder shared by the client and focused bounds tests. */ +#ifndef CWIST_REDIS_FRAME_INTERNAL_H +#define CWIST_REDIS_FRAME_INTERNAL_H +#include +#include +#include +#include + +static inline int redis_frame_fill(char *frame, size_t cap, size_t argc, const void *const *argv, + const size_t *lens) { + if (!frame || cap == 0 || argc == 0 || !argv || !lens) return -EINVAL; + int n = snprintf(frame, cap, "*%zu\r\n", argc); + if (n < 0 || (size_t)n >= cap) return -EINVAL; + size_t used = (size_t)n; + for (size_t i = 0; i < argc; ++i) { + size_t len = lens[i]; + size_t remaining = cap - used; + n = snprintf(frame + used, remaining, "$%zu\r\n", len); + if (n < 0 || (size_t)n >= remaining) return -EINVAL; + used += (size_t)n; + remaining = cap - used; + if (remaining < 3 || len > remaining - 3 || (len && !argv[i])) return -EINVAL; + if (len) memcpy(frame + used, argv[i], len); + used += len; + frame[used++] = '\r'; + frame[used++] = '\n'; + } + if (used != cap - 1) return -EINVAL; + frame[used] = '\0'; + return 0; +} +#endif diff --git a/src/sys/app/rate_limit_redis.c b/src/sys/app/rate_limit_redis.c new file mode 100644 index 000000000..385b6317e --- /dev/null +++ b/src/sys/app/rate_limit_redis.c @@ -0,0 +1,156 @@ +/** @file rate_limit_redis.c + * @brief Optional process-shared Redis rate limiter. + */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define REDIS_RATE_SLOTS 8 +#define REDIS_RATE_PREFIX_MAX 128 + +typedef struct { + cwist_redis_pool_t *pool; + int rpm; + unsigned timeout_ms; + bool fail_closed; + char prefix[REDIS_RATE_PREFIX_MAX + 1]; +} redis_rate_cfg; + +static redis_rate_cfg rate_cfgs[REDIS_RATE_SLOTS]; +static size_t rate_cfg_count; +static atomic_long rate_warning_pids[REDIS_RATE_SLOTS]; +static pthread_mutex_t rate_cfg_mutex = PTHREAD_MUTEX_INITIALIZER; + +/* A server-side window avoids process clock skew. TTL is set in the same + * atomic operation as INCR and is not extended by later or denied requests. + * Repair a pre-existing counter without an expiry instead of leaking it. */ +static const char rate_script[] = "local n=redis.call('INCR',KEYS[1]);" + "if n==1 or redis.call('PTTL',KEYS[1])<0 then " + "redis.call('PEXPIRE',KEYS[1],60000);end;return n"; + +static bool peer_ip(int fd, char ip[INET6_ADDRSTRLEN]) { + struct sockaddr_storage peer = {0}; + socklen_t size = sizeof(peer); + if (getpeername(fd, (struct sockaddr *)&peer, &size) != 0) return false; + if (peer.ss_family == AF_INET && size >= sizeof(struct sockaddr_in)) { + return inet_ntop(AF_INET, &((struct sockaddr_in *)&peer)->sin_addr, ip, INET6_ADDRSTRLEN) != + NULL; + } + if (peer.ss_family == AF_INET6 && size >= sizeof(struct sockaddr_in6)) { + struct in6_addr *addr = &((struct sockaddr_in6 *)&peer)->sin6_addr; + if (IN6_IS_ADDR_V4MAPPED(addr)) { + return inet_ntop(AF_INET, (const unsigned char *)addr + 12, ip, INET6_ADDRSTRLEN) != + NULL; + } + return inet_ntop(AF_INET6, addr, ip, INET6_ADDRSTRLEN) != NULL; + } + return false; +} + +static void backend_failure(const redis_rate_cfg *cfg, cwist_http_request *req, + cwist_http_response *res, cwist_handler_func next, int code) { + size_t slot = (size_t)(cfg - rate_cfgs); + long pid = (long)getpid(); + if (atomic_exchange_explicit(&rate_warning_pids[slot], pid, memory_order_relaxed) != pid) { + fprintf(stderr, + "[CWIST] Redis rate limiter backend error %d (%s); " + "check pool process ownership and Redis connectivity/permissions.\n", + code, cfg->fail_closed ? "fail-closed" : "fail-open"); + } + if (!cfg->fail_closed) { + next(req, res); + return; + } + res->status_code = 503; + cwist_sstring_assign(res->body, "Rate limit backend unavailable"); +} + +static void redis_rate_handler(size_t slot, cwist_http_request *req, cwist_http_response *res, + cwist_handler_func next) { + const redis_rate_cfg *cfg = &rate_cfgs[slot]; + char ip[INET6_ADDRSTRLEN]; + if (!peer_ip(req->client_fd, ip)) { + backend_failure(cfg, req, res, next, -ENOTCONN); + return; + } + char key[REDIS_RATE_PREFIX_MAX + 1 + INET6_ADDRSTRLEN]; + snprintf(key, sizeof(key), "%s%s", cfg->prefix, ip); + const void *args[] = {"EVAL", rate_script, "1", key}; + size_t lengths[] = {4, sizeof(rate_script) - 1, 1, strlen(key)}; + cwist_redis_reply_t *reply = NULL; + cwist_error_t err = cwist_redis_pool_command_argv_reply_timeout(cfg->pool, 4, args, lengths, + cfg->timeout_ms, &reply); + if (!cwist_error_is_ok(&err) || !reply || reply->type != ':' || reply->integer <= 0) { + int code = cwist_error_is_ok(&err) ? -EPROTO : err.error.err_i16; + cwist_error_dispose(&err); + cwist_redis_reply_free(reply); + backend_failure(cfg, req, res, next, code); + return; + } + bool allowed = reply->integer <= cfg->rpm; + cwist_redis_reply_free(reply); + if (allowed) { + next(req, res); + return; + } + res->status_code = 429; + cwist_sstring_assign(res->body, "Too Many Requests"); + cwist_http_header_add(&res->headers, "Retry-After", "60"); +} + +#define REDIS_RATE_WRAPPER(N) \ + static void redis_rate_wrap_##N(cwist_http_request *req, cwist_http_response *res, \ + cwist_handler_func next) { \ + redis_rate_handler(N, req, res, next); \ + } +REDIS_RATE_WRAPPER(0) +REDIS_RATE_WRAPPER(1) +REDIS_RATE_WRAPPER(2) +REDIS_RATE_WRAPPER(3) +REDIS_RATE_WRAPPER(4) +REDIS_RATE_WRAPPER(5) +REDIS_RATE_WRAPPER(6) +REDIS_RATE_WRAPPER(7) + +static cwist_middleware_func wrappers[REDIS_RATE_SLOTS] = { + redis_rate_wrap_0, redis_rate_wrap_1, redis_rate_wrap_2, redis_rate_wrap_3, + redis_rate_wrap_4, redis_rate_wrap_5, redis_rate_wrap_6, redis_rate_wrap_7, +}; + +cwist_middleware_func cwist_mw_rate_limit_ip_redis(const cwist_rate_limit_redis_cfg_t *cfg) { + if (!cfg || !cfg->pool || cfg->requests_per_minute <= 0 || cfg->timeout_ms > 60000) return NULL; + const char *prefix = cfg->key_prefix ? cfg->key_prefix : "cwist:rl:"; + if (strnlen(prefix, REDIS_RATE_PREFIX_MAX + 1) > REDIS_RATE_PREFIX_MAX) return NULL; + unsigned timeout = cfg->timeout_ms ? cfg->timeout_ms : 50; + pthread_mutex_lock(&rate_cfg_mutex); + for (size_t i = 0; i < rate_cfg_count; ++i) { + const redis_rate_cfg *old = &rate_cfgs[i]; + if (old->pool == cfg->pool && old->rpm == cfg->requests_per_minute && + old->timeout_ms == timeout && old->fail_closed == cfg->fail_closed && + strcmp(old->prefix, prefix) == 0) { + pthread_mutex_unlock(&rate_cfg_mutex); + return wrappers[i]; + } + } + if (rate_cfg_count == REDIS_RATE_SLOTS) { + pthread_mutex_unlock(&rate_cfg_mutex); + return NULL; + } + size_t slot = rate_cfg_count++; + rate_cfgs[slot] = (redis_rate_cfg){.pool = cfg->pool, + .rpm = cfg->requests_per_minute, + .timeout_ms = timeout, + .fail_closed = cfg->fail_closed}; + memcpy(rate_cfgs[slot].prefix, prefix, strlen(prefix) + 1); + pthread_mutex_unlock(&rate_cfg_mutex); + return wrappers[slot]; +} diff --git a/tests/test_rate_limit_redis.c b/tests/test_rate_limit_redis.c new file mode 100644 index 000000000..c88d50fb8 --- /dev/null +++ b/tests/test_rate_limit_redis.c @@ -0,0 +1,300 @@ +/** Integration contract for the Redis-backed limiter. */ +#ifdef NDEBUG +#error "Redis limiter tests require assertions" +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void next_ok(cwist_http_request *req, cwist_http_response *res) { + assert(req->private_data == (void *)0x403); + res->status_code = CWIST_HTTP_OK; +} + +static int peer_fd(void) { + int listener = socket(AF_INET, SOCK_STREAM, 0); + int client = socket(AF_INET, SOCK_STREAM, 0); + assert(listener >= 0 && client >= 0); + struct sockaddr_in addr = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + assert(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) == 0); + socklen_t len = sizeof(addr); + assert(getsockname(listener, (struct sockaddr *)&addr, &len) == 0); + assert(listen(listener, 1) == 0); + assert(connect(client, (struct sockaddr *)&addr, len) == 0); + int fd = accept(listener, NULL, NULL); + assert(fd >= 0); + close(client); + close(listener); + return fd; +} + +static int peer_fd6(void) { + int listener = socket(AF_INET6, SOCK_STREAM, 0); + int client = socket(AF_INET6, SOCK_STREAM, 0); + struct sockaddr_in6 address = {.sin6_family = AF_INET6, .sin6_addr = IN6ADDR_LOOPBACK_INIT}; + assert(listener >= 0 && client >= 0); + assert(bind(listener, (struct sockaddr *)&address, sizeof(address)) == 0); + socklen_t len = sizeof(address); + assert(getsockname(listener, (struct sockaddr *)&address, &len) == 0); + assert(listen(listener, 1) == 0); + assert(connect(client, (struct sockaddr *)&address, len) == 0); + int fd = accept(listener, NULL, NULL); + assert(fd >= 0); + close(client); + close(listener); + return fd; +} + +static int request(cwist_middleware_func mw, int fd) { + cwist_http_request *req = cwist_http_request_create(); + cwist_http_response *res = cwist_http_response_create(); + assert(req && res); + req->client_fd = fd; + req->private_data = (void *)0x403; + mw(req, res, next_ok); + int status = res->status_code; + if (status == 429) { + assert(strcmp(cwist_http_header_get(res->headers, "Retry-After"), "60") == 0); + } + cwist_http_request_destroy(req); + cwist_http_response_destroy(res); + return status; +} + +static char *command(cwist_redis_t *redis, size_t n, const char **args) { + size_t lengths[8]; + assert(n <= 8); + for (size_t i = 0; i < n; ++i) lengths[i] = strlen(args[i]); + char *out = NULL; + cwist_error_t err = + cwist_redis_command_argv(redis, n, (const void *const *)args, lengths, &out, NULL); + assert(cwist_error_is_ok(&err)); + return out; +} + +static long long number(cwist_redis_t *redis, const char *op, const char *key) { + const char *args[] = {op, key}; + char *out = command(redis, 2, args); + assert(out); + char *end; + long long n = strtoll(out, &end, 10); + assert(*out && !*end); + cwist_free(out); + return n; +} + +static void transfer(int fd, void *buf, size_t size, int writing) { + size_t done = 0; + while (done < size) { + ssize_t n = writing ? write(fd, (char *)buf + done, size - done) + : read(fd, (char *)buf + done, size - done); + if (n < 0 && errno == EINTR) continue; + assert(n > 0); + done += (size_t)n; + } +} + +static cwist_middleware_func limiter_budget(cwist_redis_pool_t *pool, const char *prefix, int rpm, + int fail_closed, unsigned timeout_ms) { + cwist_rate_limit_redis_cfg_t cfg = {.pool = pool, + .requests_per_minute = rpm, + .key_prefix = prefix, + .timeout_ms = timeout_ms, + .fail_closed = fail_closed}; + cwist_middleware_func mw = cwist_mw_rate_limit_ip_redis(&cfg); + assert(mw); + return mw; +} + +static cwist_middleware_func limiter(cwist_redis_pool_t *pool, const char *prefix, int rpm, + int fail_closed) { + return limiter_budget(pool, prefix, rpm, fail_closed, 500); +} + +static double now_ms(void) { + struct timespec ts; + assert(clock_gettime(CLOCK_MONOTONIC, &ts) == 0); + return ts.tv_sec * 1000.0 + ts.tv_nsec / 1000000.0; +} + +static void test_shared_processes(int port, const char *prefix) { + int start[2][2], result[2][2]; + pid_t children[2]; + for (int i = 0; i < 2; ++i) { + assert(pipe(start[i]) == 0 && pipe(result[i]) == 0); + children[i] = fork(); + assert(children[i] >= 0); + if (children[i] == 0) { + close(start[i][1]); + close(result[i][0]); + cwist_redis_pool_t *pool = cwist_redis_pool_create("127.0.0.1", port, 1); + assert(pool); + cwist_middleware_func mw = limiter(pool, prefix, 7, 1); + int fd = peer_fd(); + char ready = 1; + transfer(result[i][1], &ready, 1, 1); + transfer(start[i][0], &ready, 1, 0); + int counts[2] = {0}; + for (int j = 0; j < 20; ++j) { + int status = request(mw, fd); + assert(status == 200 || status == 429); + ++counts[status == 429]; + } + transfer(result[i][1], counts, sizeof(counts), 1); + close(fd); + cwist_redis_pool_destroy(pool); + _exit(0); + } + close(start[i][0]); + close(result[i][1]); + } + char ready; + for (int i = 0; i < 2; ++i) transfer(result[i][0], &ready, 1, 0); + for (int i = 0; i < 2; ++i) transfer(start[i][1], &ready, 1, 1); + int total[2] = {0}; + for (int i = 0; i < 2; ++i) { + int counts[2], status; + transfer(result[i][0], counts, sizeof(counts), 0); + assert(waitpid(children[i], &status, 0) == children[i]); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + total[0] += counts[0]; + total[1] += counts[1]; + close(start[i][1]); + close(result[i][0]); + } + assert(total[0] == 7 && total[1] == 33); + printf("[PASS] two processes share exactly 7 admits and 33 rejections\n"); +} + +static void test_inherited_warning(cwist_middleware_func mw, int fd) { + int capture[2]; + assert(pipe(capture) == 0); + fflush(NULL); + pid_t child = fork(); + assert(child >= 0); + if (child == 0) { + close(capture[0]); + assert(dup2(capture[1], STDERR_FILENO) >= 0); + close(capture[1]); + for (int i = 0; i < 3; ++i) assert(request(mw, fd) == 200); + fflush(stderr); + _exit(0); + } + close(capture[1]); + char text[2048] = {0}; + size_t used = 0; + ssize_t n; + while ((n = read(capture[0], text + used, sizeof(text) - 1 - used)) > 0) used += (size_t)n; + assert(n == 0); + close(capture[0]); + int status; + assert(waitpid(child, &status, 0) == child); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + char *warning = strstr(text, "Redis rate limiter backend error"); + assert(warning && strstr(warning + 1, "Redis rate limiter backend error") == NULL); + assert(strstr(text, "fail-open")); + puts("[PASS] inherited pool warns once in each process"); +} + +int main(void) { + assert(cwist_mw_rate_limit_ip_redis(NULL) == NULL); + cwist_rate_limit_redis_cfg_t invalid = {0}; + assert(cwist_mw_rate_limit_ip_redis(&invalid) == NULL); + invalid.pool = (cwist_redis_pool_t *)1; /* Validation must not dereference it. */ + assert(cwist_mw_rate_limit_ip_redis(&invalid) == NULL); + invalid.requests_per_minute = 1; + invalid.timeout_ms = 60001; + assert(cwist_mw_rate_limit_ip_redis(&invalid) == NULL); + invalid.timeout_ms = 0; + char too_long[130]; + memset(too_long, 'x', sizeof(too_long) - 1); + too_long[sizeof(too_long) - 1] = '\0'; + invalid.key_prefix = too_long; + assert(cwist_mw_rate_limit_ip_redis(&invalid) == NULL); + const char *value = getenv("CWIST_REDIS_RATE_LIMIT_TEST_PORT"); + if (!value) { + assert(!getenv("CWIST_REDIS_RATE_LIMIT_REQUIRE")); + puts("[SKIP] real Redis checks need the owned-Redis runner"); + return 0; + } + int port = atoi(value); + assert(port > 0); + char prefix[96], key[128]; + snprintf(prefix, sizeof(prefix), "cwist:test:rl:%ld:", (long)getpid()); + snprintf(key, sizeof(key), "%s127.0.0.1", prefix); + test_shared_processes(port, prefix); + cwist_redis_t *redis = cwist_redis_connect("127.0.0.1", port); + cwist_redis_pool_t *pool = cwist_redis_pool_create("127.0.0.1", port, 1); + assert(redis && pool); + assert(number(redis, "GET", key) == 40); + assert(number(redis, "PTTL", key) > 0 && number(redis, "PTTL", key) <= 60000); + int fd = peer_fd(); + cwist_middleware_func shared = limiter(pool, prefix, 7, 1); + const char *expire[] = {"PEXPIRE", key, "1000"}; + cwist_free(command(redis, 3, expire)); + assert(request(shared, fd) == 429); + assert(number(redis, "PTTL", key) <= 1000); + puts("[PASS] rejected requests do not extend the Redis TTL"); + const char *persist[] = {"PERSIST", key}; + cwist_free(command(redis, 2, persist)); + assert(number(redis, "PTTL", key) == -1); + assert(request(shared, fd) == 429); + assert(number(redis, "PTTL", key) > 0); + puts("[PASS] missing expiry is repaired atomically"); + int ipv6 = peer_fd6(); + assert(request(shared, ipv6) == 200); + close(ipv6); + puts("[PASS] distinct IPv6 peer has an independent bucket"); + const char *expire_now[] = {"PEXPIRE", key, "1"}; + cwist_free(command(redis, 3, expire_now)); + for (int i = 0; number(redis, "PTTL", key) != -2; ++i) { + assert(i < 100); + struct timespec pause = {.tv_nsec = 1000000}; + nanosleep(&pause, NULL); + } + assert(request(shared, fd) == 200); + assert(number(redis, "GET", key) == 1); + puts("[PASS] key expiry opens a fresh shared window"); + char other[sizeof(prefix) + sizeof("other:")]; + snprintf(other, sizeof(other), "%sother:", prefix); + cwist_middleware_func isolated = limiter(pool, other, 1, 1); + assert(request(isolated, fd) == 200 && request(isolated, fd) == 429); + puts("[PASS] independent prefixes have independent limits"); + cwist_middleware_func fail_open = limiter(pool, prefix, 7, 0); + assert(request(fail_open, -1) == 200); + assert(request(shared, -1) == 503); + const char *wrong_type[] = {"SET", key, "not-an-integer"}; + cwist_free(command(redis, 3, wrong_type)); + assert(request(shared, fd) == 503); + assert(request(fail_open, fd) == 200); + puts("[PASS] Redis command errors and missing peers obey failure policy"); + const char *remove[] = {"DEL", key}; + cwist_free(command(redis, 2, remove)); + cwist_middleware_func short_open = limiter_budget(pool, prefix, 7, 0, 25); + cwist_middleware_func short_closed = limiter_budget(pool, prefix, 7, 1, 25); + const char *pause[] = {"CLIENT", "PAUSE", "500", "ALL"}; + cwist_free(command(redis, 4, pause)); + double start = now_ms(); + assert(request(short_open, fd) == 200); + assert(now_ms() - start < 150); + start = now_ms(); + assert(request(short_closed, fd) == 503); + assert(now_ms() - start < 150); + puts("[PASS] paused Redis obeys bounded fail-open and fail-closed policies"); + test_inherited_warning(fail_open, fd); + close(fd); + cwist_redis_pool_destroy(pool); + cwist_redis_close(redis); + puts("All Redis rate limiter integration checks passed."); + return 0; +} diff --git a/tests/test_redis_frame.c b/tests/test_redis_frame.c new file mode 100644 index 000000000..04b8f3d97 --- /dev/null +++ b/tests/test_redis_frame.c @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include "../src/net/redis/redis_frame_internal.h" + +static char fault_kind; +static int fault_mode; +static int failed_format(char *buf, size_t cap, const char *fmt, ...) { + if (fmt[0] == fault_kind) { + if (fault_mode == 0) return -1; + return fault_mode == 1 ? (int)cap : 0; + } + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, cap, fmt, args); + va_end(args); + return n; +} + +/* Compile the same encoder with a controlled formatter failure. */ +#undef CWIST_REDIS_FRAME_INTERNAL_H +#define redis_frame_fill redis_frame_fill_fault +#undef snprintf +#define snprintf failed_format +#include "../src/net/redis/redis_frame_internal.h" +#undef snprintf +#undef redis_frame_fill + +#define CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "frame check failed at %d\n", __LINE__); \ + return 1; \ + } \ + } while (0) + +int main(void) { + const void *args[] = {"ECHO", "hello"}; + const size_t lens[] = {4, 5}; + const char expected[] = "*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n"; + unsigned char guarded[sizeof(expected) + 2]; + memset(guarded, 0xa5, sizeof(guarded)); + CHECK(redis_frame_fill((char *)guarded + 1, sizeof(expected), 2, args, lens) == 0); + CHECK(memcmp(guarded + 1, expected, sizeof(expected)) == 0); + const void *binary[] = {"ECHO", "a\0b"}; + const size_t binary_lens[] = {4, 3}; + const char binary_expected[] = "*2\r\n$4\r\nECHO\r\n$3\r\na\0b\r\n"; + CHECK(redis_frame_fill((char *)guarded + 1, sizeof(binary_expected), 2, binary, binary_lens) == + 0); + CHECK(memcmp(guarded + 1, binary_expected, sizeof(binary_expected)) == 0); + for (size_t cap = 0; cap < sizeof(expected); ++cap) { + memset(guarded, 0xa5, sizeof(guarded)); + CHECK(redis_frame_fill((char *)guarded + 1, cap, 2, args, lens) == -EINVAL); + CHECK(guarded[0] == 0xa5); + for (size_t i = cap + 1; i < sizeof(guarded); ++i) CHECK(guarded[i] == 0xa5); + } + for (int k = 0; k < 2; ++k) { + fault_kind = k ? '$' : '*'; + for (fault_mode = 0; fault_mode < 3; ++fault_mode) { + memset(guarded, 0xa5, sizeof(guarded)); + CHECK(redis_frame_fill_fault((char *)guarded + 1, sizeof(expected), 2, args, lens) == + -EINVAL); + CHECK(guarded[0] == 0xa5 && guarded[sizeof(guarded) - 1] == 0xa5); + } + } + puts("Redis frame bounds and formatter failure checks passed"); + return 0; +} diff --git a/tests/test_redis_timeout.c b/tests/test_redis_timeout.c new file mode 100644 index 000000000..f52442cba --- /dev/null +++ b/tests/test_redis_timeout.c @@ -0,0 +1,1097 @@ +/** + * @file test_redis_timeout.c + * @brief Deadline-bounded pooled Redis command tests against scripted, + * real-TCP fake RESP peers (no Redis server required). + * + * The fake peer interprets the command the client sends: + * ECHO tag -> bulk tag + * STALL ms tag -> sleep ms, then bulk tag + * TRICKLE ms tag -> bulk tag written one byte every ms + * RAW bytes -> bytes verbatim (malformed/typed replies) + * RAWCLOSE bytes -> bytes verbatim, then close + * CLOSE -> close without replying + * REPLYCLOSE tag -> bulk tag, then close (idle conn goes stale) + * UNSOL tag extra -> bulk tag, then unsolicited extra bytes + * DOUBLE tag -> bulk tag twice in a single write + * A server-wide mode can stop reading (write stall) or reset the connection + * after the first read (EPIPE/SIGPIPE). + */ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +static int g_failures; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, " FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + g_failures++; \ + } \ + } while (0) + +/* --- getaddrinfo() interposer: proves reconnect performs no DNS ---------- */ + +typedef int (*gai_fn)(const char *, const char *, const struct addrinfo *, struct addrinfo **); +static gai_fn g_real_gai; +static atomic_int g_dns_calls; +static atomic_bool g_dns_block; +static atomic_bool g_pipe_failure; +typedef int (*pipe_fn)(int *); +static pipe_fn g_real_pipe; + +int pipe(int fds[2]) { + if (atomic_load(&g_pipe_failure) || !g_real_pipe) { + errno = EMFILE; + return -1; + } + return g_real_pipe(fds); +} + +int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, + struct addrinfo **res) { + atomic_fetch_add(&g_dns_calls, 1); + if (atomic_load(&g_dns_block) || !g_real_gai) return EAI_FAIL; + return g_real_gai(node, service, hints, res); +} + +static void dns_init(void) { + void *sym = dlsym(RTLD_NEXT, "getaddrinfo"); + memcpy(&g_real_gai, &sym, sizeof(g_real_gai)); + sym = dlsym(RTLD_NEXT, "pipe"); + memcpy(&g_real_pipe, &sym, sizeof(g_real_pipe)); +} + +/* --- time helpers -------------------------------------------------------- */ + +static double now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1e3 + (double)ts.tv_nsec / 1e6; +} + +/* poll() rather than nanosleep(): on background-QoS macOS, nanosleep and + * condition timeouts are coalesced by up to ~150 ms, poll() stays precise. */ +static void sleep_ms(long ms) { + double end = now_ms() + (double)ms; + for (double left = (double)ms; left > 0; left = end - now_ms()) + (void)poll(NULL, 0, (int)left + 1); +} + +/* --- fake RESP peer ------------------------------------------------------ */ + +enum { MODE_NORMAL = 0, MODE_NOREAD = 1, MODE_RST_ON_READ = 2 }; +#define LOG_MAX 8192 +#define MAX_ARGS 8 +#define SRV_BUF_MAX ((size_t)64 * 1024 * 1024) + +typedef struct fake_server { + int listen_fd; + int port; + pthread_t accept_thread; + bool listener_open; + atomic_bool stop_accept; + atomic_bool stop; + atomic_int mode; + atomic_int accepted; + atomic_int active; + pthread_mutex_t mu; + char *log[LOG_MAX]; + int nlog; +} fake_server; + +typedef struct conn_ctx { + fake_server *s; + int fd; +} conn_ctx; + +static int srv_send(int fd, const void *buf, size_t len) { + const char *p = buf; + while (len) { + ssize_t n = send(fd, p, len, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + p += n; + len -= (size_t)n; + } + return 0; +} + +static char *bulk_frame(const char *s, size_t len, size_t *out_len) { + char hdr[32]; + int h = snprintf(hdr, sizeof(hdr), "$%zu\r\n", len); + size_t total = (size_t)h + len + 2; + char *b = malloc(total); + memcpy(b, hdr, (size_t)h); + if (len) memcpy(b + h, s, len); + memcpy(b + h + len, "\r\n", 2); + *out_len = total; + return b; +} + +static int srv_bulk(int fd, const char *s, size_t len) { + size_t n = 0; + char *b = bulk_frame(s, len, &n); + int rc = srv_send(fd, b, n); + free(b); + return rc; +} + +static void srv_log(fake_server *s, const char *name, size_t nlen, const char *last, size_t llen) { + if (llen > 64) llen = 64; + char *e = malloc(nlen + llen + 2); + memcpy(e, name, nlen); + e[nlen] = ' '; + memcpy(e + nlen + 1, last, llen); + e[nlen + 1 + llen] = '\0'; + pthread_mutex_lock(&s->mu); + if (s->nlog < LOG_MAX) + s->log[s->nlog++] = e; + else + free(e); + pthread_mutex_unlock(&s->mu); +} + +/* Count log entries equal to @p entry (or starting with it if prefix). */ +static int log_count(fake_server *s, const char *entry, bool prefix) { + int n = 0; + pthread_mutex_lock(&s->mu); + for (int i = 0; i < s->nlog; i++) { + if (prefix ? strncmp(s->log[i], entry, strlen(entry)) == 0 : strcmp(s->log[i], entry) == 0) + n++; + } + pthread_mutex_unlock(&s->mu); + return n; +} + +static bool wait_log(fake_server *s, const char *entry, int ms) { + double end = now_ms() + ms; + while (now_ms() < end) { + if (log_count(s, entry, false) > 0) return true; + sleep_ms(2); + } + return false; +} + +static int wait_accepted(fake_server *s, int want, int ms) { + double end = now_ms() + ms; + while (atomic_load(&s->accepted) < want && now_ms() < end) sleep_ms(2); + return atomic_load(&s->accepted); +} + +static void srv_stall(fake_server *s, long ms) { + double end = now_ms() + (double)ms; + while (!atomic_load(&s->stop) && now_ms() < end) sleep_ms(2); +} + +static long arg_num(const char *p, size_t n) { + char tmp[32]; + if (n >= sizeof(tmp)) return 0; + memcpy(tmp, p, n); + tmp[n] = '\0'; + return strtol(tmp, NULL, 10); +} + +/* 1 = parsed, 0 = need more bytes, -1 = malformed. */ +static int parse_num_line(const char *b, size_t len, size_t *pos, char prefix, long long *out) { + if (*pos >= len) return 0; + if (b[*pos] != prefix) return -1; + const char *nl = memchr(b + *pos, '\n', len - *pos); + if (!nl) return (len - *pos > 32) ? -1 : 0; + size_t end = (size_t)(nl - b); + if (end < *pos + 2 || b[end - 1] != '\r') return -1; + char tmp[32]; + size_t n = end - 1 - (*pos + 1); + if (n == 0 || n >= sizeof(tmp)) return -1; + memcpy(tmp, b + *pos + 1, n); + tmp[n] = '\0'; + char *ep = NULL; + long long v = strtoll(tmp, &ep, 10); + if (*ep || v < 0) return -1; + *out = v; + *pos = end + 1; + return 1; +} + +static int parse_cmd(const char *b, size_t len, size_t *used, const char **args, size_t *lens, + int *argc) { + size_t pos = 0; + long long n = 0; + int r = parse_num_line(b, len, &pos, '*', &n); + if (r <= 0) return r; + if (n < 1 || n > MAX_ARGS) return -1; + for (long long i = 0; i < n; i++) { + long long l = 0; + r = parse_num_line(b, len, &pos, '$', &l); + if (r <= 0) return r; + if ((unsigned long long)l > SRV_BUF_MAX) return -1; + if (len - pos < (size_t)l + 2) return 0; + if (b[pos + (size_t)l] != '\r' || b[pos + (size_t)l + 1] != '\n') return -1; + args[i] = b + pos; + lens[i] = (size_t)l; + pos += (size_t)l + 2; + } + *argc = (int)n; + *used = pos; + return 1; +} + +/* Returns false when the connection must be closed. */ +static bool srv_exec(fake_server *s, int fd, int argc, const char **a, const size_t *l) { + srv_log(s, a[0], l[0], a[argc - 1], l[argc - 1]); +#define IS(x) (l[0] == strlen(x) && memcmp(a[0], (x), l[0]) == 0) + if (IS("ECHO") && argc == 2) return srv_bulk(fd, a[1], l[1]) == 0; + if (IS("STALL") && argc == 3) { + srv_stall(s, arg_num(a[1], l[1])); + return srv_bulk(fd, a[2], l[2]) == 0; + } + if (IS("TRICKLE") && argc == 3) { + long ms = arg_num(a[1], l[1]); + size_t n = 0; + char *b = bulk_frame(a[2], l[2], &n); + bool ok = true; + for (size_t i = 0; i < n && ok; i++) { + ok = srv_send(fd, b + i, 1) == 0; + if (ok && i + 1 < n) srv_stall(s, ms); + } + free(b); + return ok; + } + if (IS("RAW") && argc == 2) return srv_send(fd, a[1], l[1]) == 0; + if (IS("RAWCLOSE") && argc == 2) { + srv_send(fd, a[1], l[1]); + return false; + } + if (IS("CLOSE")) return false; + if (IS("REPLYCLOSE") && argc == 2) { + srv_bulk(fd, a[1], l[1]); + return false; + } + if (IS("UNSOL") && argc == 3) { + if (srv_bulk(fd, a[1], l[1]) != 0) return false; + srv_stall(s, 30); + return srv_send(fd, a[2], l[2]) == 0; + } + if (IS("DOUBLE") && argc == 2) { + size_t n = 0; + char *b = bulk_frame(a[1], l[1], &n); + char *two = malloc(n * 2); + memcpy(two, b, n); + memcpy(two + n, b, n); + int rc = srv_send(fd, two, n * 2); + free(two); + free(b); + return rc == 0; + } +#undef IS + return srv_send(fd, "-ERR unknown\r\n", 14) == 0; +} + +static void *srv_conn(void *arg) { + conn_ctx *c = arg; + fake_server *s = c->s; + int fd = c->fd; + free(c); + size_t cap = 65536, len = 0; + char *buf = malloc(cap); + bool open = true; + while (open && !atomic_load(&s->stop)) { + if (atomic_load(&s->mode) == MODE_NOREAD) { + sleep_ms(5); + continue; + } + struct pollfd p = {fd, POLLIN, 0}; + if (poll(&p, 1, 20) <= 0) continue; + if (len == cap) { + if (cap >= SRV_BUF_MAX) break; + cap *= 2; + buf = realloc(buf, cap); + } + ssize_t n = recv(fd, buf + len, cap - len, 0); + if (n <= 0) break; + if (atomic_load(&s->mode) == MODE_RST_ON_READ) { + struct linger lg = {1, 0}; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg)); + close(fd); + fd = -1; + break; + } + len += (size_t)n; + for (;;) { + const char *args[MAX_ARGS]; + size_t lens[MAX_ARGS]; + int argc = 0; + size_t used = 0; + int r = parse_cmd(buf, len, &used, args, lens, &argc); + if (r < 0) open = false; + if (r <= 0) break; + if (!srv_exec(s, fd, argc, args, lens)) { + open = false; + break; + } + memmove(buf, buf + used, len - used); + len -= used; + } + } + if (fd >= 0) { + shutdown(fd, SHUT_RDWR); + close(fd); + } + free(buf); + atomic_fetch_sub(&s->active, 1); + return NULL; +} + +static void *srv_accept(void *arg) { + fake_server *s = arg; + while (!atomic_load(&s->stop_accept)) { + struct pollfd p = {s->listen_fd, POLLIN, 0}; + if (poll(&p, 1, 20) <= 0) continue; + int fd = accept(s->listen_fd, NULL, NULL); + if (fd < 0) continue; +#ifdef SO_NOSIGPIPE + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); +#endif + atomic_fetch_add(&s->accepted, 1); + atomic_fetch_add(&s->active, 1); + conn_ctx *c = malloc(sizeof(*c)); + c->s = s; + c->fd = fd; + pthread_t t; + if (pthread_create(&t, NULL, srv_conn, c) != 0) { + close(fd); + free(c); + atomic_fetch_sub(&s->active, 1); + continue; + } + pthread_detach(t); + } + return NULL; +} + +static fake_server *server_start(void) { + fake_server *s = calloc(1, sizeof(*s)); + pthread_mutex_init(&s->mu, NULL); + s->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(s->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in sa = {0}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + sa.sin_port = 0; + if (bind(s->listen_fd, (struct sockaddr *)&sa, sizeof(sa)) != 0 || + listen(s->listen_fd, 64) != 0) { + perror("fake server"); + exit(2); + } + socklen_t sl = sizeof(sa); + getsockname(s->listen_fd, (struct sockaddr *)&sa, &sl); + s->port = ntohs(sa.sin_port); + s->listener_open = true; + pthread_create(&s->accept_thread, NULL, srv_accept, s); + return s; +} + +static void server_close_listener(fake_server *s) { + if (!s->listener_open) return; + atomic_store(&s->stop_accept, true); + pthread_join(s->accept_thread, NULL); + close(s->listen_fd); + s->listener_open = false; +} + +static void server_stop(fake_server *s) { + server_close_listener(s); + atomic_store(&s->stop, true); + double end = now_ms() + 5000; + while (atomic_load(&s->active) > 0 && now_ms() < end) sleep_ms(5); + CHECK(atomic_load(&s->active) == 0); + for (int i = 0; i < s->nlog; i++) free(s->log[i]); + pthread_mutex_destroy(&s->mu); + free(s); +} + +/* --- client helpers ------------------------------------------------------ */ + +static cwist_redis_reply_t g_sentinel; + +static int ecode(cwist_error_t e) { + return e.errtype == CWIST_ERR_INT16 ? (int)e.error.err_i16 : INT_MIN; +} + +static int cmdv(cwist_redis_pool_t *p, unsigned timeout, cwist_redis_reply_t **reply, size_t argc, + const char **argv) { + size_t lens[MAX_ARGS]; + for (size_t i = 0; i < argc; i++) lens[i] = strlen(argv[i]); + *reply = &g_sentinel; + cwist_error_t e = cwist_redis_pool_command_argv_reply_timeout( + p, argc, (const void *const *)argv, lens, timeout, reply); + return ecode(e); +} + +#define CMD(p, t, r, ...) \ + cmdv((p), (t), (r), sizeof((const char *[]){__VA_ARGS__}) / sizeof(const char *), \ + (const char *[]){__VA_ARGS__}) + +static int raw_cmd(cwist_redis_pool_t *p, unsigned timeout, const char *bytes, size_t len, + cwist_redis_reply_t **reply, const char *verb) { + const void *argv[] = {verb, bytes}; + size_t lens[] = {strlen(verb), len}; + *reply = &g_sentinel; + return ecode(cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, lens, timeout, reply)); +} + +static bool is_bulk(const cwist_redis_reply_t *r, const char *s) { + return r && r != &g_sentinel && r->type == '$' && r->str && r->len == strlen(s) && + memcmp(r->str, s, r->len) == 0; +} + +static void reply_drop(cwist_redis_reply_t *r) { + if (r != &g_sentinel) cwist_redis_reply_free(r); +} + +/* Run ECHO tag and expect it back. */ +static bool echo_ok(cwist_redis_pool_t *p, const char *tag) { + cwist_redis_reply_t *r = NULL; + int c = CMD(p, 2000, &r, "ECHO", tag); + bool ok = c == 0 && is_bulk(r, tag); + if (!ok) fprintf(stderr, " echo %s -> code %d\n", tag, c); + reply_drop(r); + return ok; +} + +typedef struct legacy_job { + cwist_redis_pool_t *p; + const char *argv[3]; + size_t argc; + char *out; + int code; + atomic_bool done; +} legacy_job; + +static void *legacy_thread(void *arg) { + legacy_job *j = arg; + size_t lens[3]; + for (size_t i = 0; i < j->argc; i++) lens[i] = strlen(j->argv[i]); + size_t out_len = 0; + cwist_error_t e = cwist_redis_pool_command_argv(j->p, j->argc, (const void *const *)j->argv, + lens, &j->out, &out_len); + j->code = ecode(e); + atomic_store(&j->done, true); + return NULL; +} + +/* --- tests --------------------------------------------------------------- */ + +static void test_invalid_inputs(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + const void *argv[] = {"ECHO", "x"}; + size_t lens[] = {4, 1}; + cwist_redis_reply_t *r = NULL; +#define EXPECT_EINVAL(call) \ + do { \ + r = &g_sentinel; \ + cwist_error_t e_ = (call); \ + CHECK(ecode(e_) == -EINVAL); \ + CHECK(r == NULL); \ + } while (0) + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(NULL, 2, argv, lens, 100, &r)); + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 0, argv, lens, 100, &r)); + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 2, NULL, lens, 100, &r)); + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, NULL, 100, &r)); + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, lens, 0, &r)); + EXPECT_EINVAL( + cwist_redis_pool_command_argv_reply_timeout(p, (size_t)1 << 40, argv, lens, 100, &r)); + { + const void *nv[] = {"ECHO", NULL}; + size_t nl[] = {4, 3}; + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 2, nv, nl, 100, &r)); + } + { + size_t huge[] = {4, SIZE_MAX}; + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, huge, 100, &r)); + } + { + size_t ov[] = {SIZE_MAX / 2 + 1, SIZE_MAX / 2 + 1}; + EXPECT_EINVAL(cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, ov, 100, &r)); + } +#undef EXPECT_EINVAL + CHECK(ecode(cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, lens, 100, NULL)) == + -EINVAL); + sleep_ms(50); + CHECK(log_count(s, "", true) == 0); /* nothing reached the peer */ + + /* A NULL pointer with zero length is a valid empty argument. */ + const void *ev[] = {"ECHO", NULL}; + size_t el[] = {4, 0}; + r = &g_sentinel; + CHECK(ecode(cwist_redis_pool_command_argv_reply_timeout(p, 2, ev, el, 1000, &r)) == 0); + CHECK(r && r != &g_sentinel && r->type == '$' && r->str && r->len == 0); + reply_drop(r); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_typed_replies(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + int base = wait_accepted(s, 1, 1000); + cwist_redis_reply_t *r = NULL; + + CHECK(CMD(p, 1000, &r, "ECHO", "hello") == 0); + CHECK(is_bulk(r, "hello")); + reply_drop(r); + + CHECK(raw_cmd(p, 1000, ":42\r\n", 5, &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == ':' && r->integer == 42); + reply_drop(r); + + const char *mn = ":-9223372036854775808\r\n"; + CHECK(raw_cmd(p, 1000, mn, strlen(mn), &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == ':' && r->integer == LLONG_MIN); + reply_drop(r); + + CHECK(raw_cmd(p, 1000, "+OK\r\n", 5, &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == '+' && r->str && strcmp(r->str, "OK") == 0 && + r->len == 2); + reply_drop(r); + + CHECK(raw_cmd(p, 1000, "$3\r\na\0b\r\n", 9, &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == '$' && r->len == 3 && r->str && + memcmp(r->str, "a\0b", 3) == 0 && r->str[3] == '\0'); + reply_drop(r); + + CHECK(raw_cmd(p, 1000, "$-1\r\n", 5, &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == '$' && r->str == NULL && r->integer == -1); + reply_drop(r); + + CHECK(raw_cmd(p, 1000, "*-1\r\n", 5, &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == '*' && r->elements == 0 && r->element == NULL && + r->integer == -1); + reply_drop(r); + + CHECK(raw_cmd(p, 1000, "*0\r\n", 4, &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == '*' && r->elements == 0 && r->integer == 0); + reply_drop(r); + + const char *nested = "*3\r\n:1\r\n-ERR inner\r\n*1\r\n$1\r\nx\r\n"; + CHECK(raw_cmd(p, 1000, nested, strlen(nested), &r, "RAW") == 0); + CHECK(r && r != &g_sentinel && r->type == '*' && r->elements == 3); + if (r && r != &g_sentinel && r->elements == 3) { + CHECK(r->element[0]->type == ':' && r->element[0]->integer == 1); + CHECK(r->element[1]->type == '-' && strcmp(r->element[1]->str, "ERR inner") == 0); + CHECK(r->element[2]->type == '*' && r->element[2]->elements == 1 && + is_bulk(r->element[2]->element[0], "x")); + } + reply_drop(r); + + /* A top-level Redis error reply fails with -EBADMSG and NULL reply, but + * the connection is still in sync and is reused (no reconnect). */ + CHECK(raw_cmd(p, 1000, "-ERR boom\r\n", 11, &r, "RAW") == -EBADMSG); + CHECK(r == NULL); + CHECK(echo_ok(p, "after-error")); + sleep_ms(30); + CHECK(atomic_load(&s->accepted) == base); + + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_stall_deadline_and_stale_reply(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + int base = wait_accepted(s, 1, 1000); + cwist_redis_reply_t *r = NULL; + + double t0 = now_ms(); + int c = CMD(p, 150, &r, "STALL", "1200", "late-a"); + double el = now_ms() - t0; + CHECK(c == -ETIMEDOUT); + CHECK(r == NULL); + CHECK(el >= 140 && el < 450); + fprintf(stderr, " stall: code %d after %.1f ms\n", c, el); + + /* The timed-out connection must be discarded: the next command gets its + * own reply on a fresh connection, never the late "late-a". */ + CHECK(echo_ok(p, "b")); + CHECK(wait_accepted(s, base + 1, 1000) == base + 1); + + /* A slot discarded by the timed API stays usable by the legacy API. */ + CHECK(CMD(p, 100, &r, "STALL", "1000", "late-l") == -ETIMEDOUT); + { + legacy_job lj = {.p = p, .argv = {"ECHO", "legacy-after"}, .argc = 2}; + legacy_thread(&lj); + CHECK(lj.out && strcmp(lj.out, "legacy-after") == 0); + cwist_free(lj.out); + } + + /* Even after the late replies have been written by the peer. */ + sleep_ms(1250); + CHECK(echo_ok(p, "c")); + CHECK(log_count(s, "STALL late-a", false) == 1); /* sent once, never retried */ + + /* EOF after the command was sent is ambiguous: fail, never retry. */ + CHECK(CMD(p, 1000, &r, "CLOSE", "once") == -EIO); + CHECK(r == NULL); + sleep_ms(30); + CHECK(log_count(s, "CLOSE once", false) == 1); + CHECK(echo_ok(p, "after-close")); + + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_trickle_deadline(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + cwist_redis_reply_t *r = NULL; + + /* ~23 bytes at 40 ms/byte: every recv makes progress, so only a total + * deadline (not a per-read timeout) can bound this. */ + double t0 = now_ms(); + int c = CMD(p, 200, &r, "TRICKLE", "40", "abcdefghijklmnop"); + double el = now_ms() - t0; + CHECK(c == -ETIMEDOUT); + CHECK(r == NULL); + CHECK(el >= 190 && el < 500); + fprintf(stderr, " trickle: code %d after %.1f ms\n", c, el); + + /* A trickle that completes within the budget succeeds. */ + CHECK(CMD(p, 2000, &r, "TRICKLE", "5", "xyz") == 0); + CHECK(is_bulk(r, "xyz")); + reply_drop(r); + CHECK(echo_ok(p, "after-trickle")); + + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_write_deadline(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + size_t big_len = (size_t)16 * 1024 * 1024; + char *big = malloc(big_len); + memset(big, 'w', big_len); + cwist_redis_reply_t *r = NULL; + + atomic_store(&s->mode, MODE_NOREAD); + double t0 = now_ms(); + int c = raw_cmd(p, 200, big, big_len, &r, "ECHO"); + double el = now_ms() - t0; + atomic_store(&s->mode, MODE_NORMAL); + CHECK(c == -ETIMEDOUT); + CHECK(r == NULL); + CHECK(el >= 190 && el < 500); + fprintf(stderr, " write stall: code %d after %.1f ms\n", c, el); + + /* The partially written frame must never be completed or reused. */ + CHECK(echo_ok(p, "after-write-stall")); + sleep_ms(50); + CHECK(log_count(s, "ECHO www", true) == 0); + free(big); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_sigpipe_contained(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + size_t big_len = (size_t)16 * 1024 * 1024; + char *big = malloc(big_len); + memset(big, 'p', big_len); + cwist_redis_reply_t *r = NULL; + + /* SIGPIPE has its default (fatal) disposition here; the peer resets the + * connection mid-write, so an uncontained EPIPE would kill the test. */ + atomic_store(&s->mode, MODE_RST_ON_READ); + int c = raw_cmd(p, 3000, big, big_len, &r, "ECHO"); + atomic_store(&s->mode, MODE_NORMAL); + CHECK(c == -EIO); + CHECK(r == NULL); + fprintf(stderr, " reset mid-write: code %d (process alive)\n", c); + CHECK(echo_ok(p, "after-reset")); + free(big); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_pool_saturation(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + cwist_redis_reply_t *r = NULL; + + /* A legacy caller holds the only connection. */ + legacy_job a = {.p = p, .argv = {"STALL", "700", "holdA"}, .argc = 3}; + pthread_t ta; + pthread_create(&ta, NULL, legacy_thread, &a); + CHECK(wait_log(s, "STALL holdA", 1000)); + + double t0 = now_ms(); + int c = CMD(p, 120, &r, "ECHO", "tagT"); + double el = now_ms() - t0; + CHECK(c == -ETIMEDOUT); + CHECK(r == NULL); + CHECK(el >= 110 && el < 200); /* acquisition wait is not timer-coalesced */ + fprintf(stderr, " saturated acquire: code %d after %.1f ms\n", c, el); + + pthread_join(ta, NULL); + CHECK(a.out && strcmp(a.out, "holdA") == 0); + cwist_free(a.out); + sleep_ms(30); + CHECK(log_count(s, "ECHO tagT", false) == 0); /* never sent */ + + /* A waiter whose budget outlasts the holder gets the slot. */ + legacy_job b = {.p = p, .argv = {"STALL", "150", "holdB"}, .argc = 3}; + pthread_t tb; + pthread_create(&tb, NULL, legacy_thread, &b); + CHECK(wait_log(s, "STALL holdB", 1000)); + t0 = now_ms(); + c = CMD(p, 3000, &r, "ECHO", "tagW"); + el = now_ms() - t0; + CHECK(c == 0 && is_bulk(r, "tagW")); + CHECK(el >= 80); + reply_drop(r); + pthread_join(tb, NULL); + cwist_free(b.out); + + /* Timed waiters giving up must not strand a legacy waiter. */ + legacy_job h = {.p = p, .argv = {"STALL", "300", "holdC"}, .argc = 3}; + legacy_job w = {.p = p, .argv = {"ECHO", "tagL"}, .argc = 2}; + pthread_t th, tw; + pthread_create(&th, NULL, legacy_thread, &h); + CHECK(wait_log(s, "STALL holdC", 1000)); + pthread_create(&tw, NULL, legacy_thread, &w); + sleep_ms(20); + for (int i = 0; i < 3; i++) { + double tx = now_ms(); + c = CMD(p, 40, &r, "ECHO", "tagX"); + double ex = now_ms() - tx; + if (c != -ETIMEDOUT || ex >= 120) + fprintf(stderr, " timed waiter %d: code %d after %.1f ms\n", i, c, ex); + CHECK(c == -ETIMEDOUT); + CHECK(ex >= 35 && ex < 120); + CHECK(r == NULL); + reply_drop(r); + } + pthread_join(th, NULL); + double end = now_ms() + 2000; + while (!atomic_load(&w.done) && now_ms() < end) sleep_ms(5); + CHECK(atomic_load(&w.done)); + if (atomic_load(&w.done)) { + pthread_join(tw, NULL); + CHECK(w.out && strcmp(w.out, "tagL") == 0); + cwist_free(w.out); + } + cwist_free(h.out); + CHECK(echo_ok(p, "after-saturation")); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_no_wake_pipe_deadline(void) { + fake_server *s = server_start(); + atomic_store(&g_pipe_failure, true); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + atomic_store(&g_pipe_failure, false); + CHECK(p != NULL); + legacy_job job = {.p = p, .argv = {"STALL", "500", "noPipe"}, .argc = 3}; + pthread_t thread; + pthread_create(&thread, NULL, legacy_thread, &job); + CHECK(wait_log(s, "STALL noPipe", 1000)); + double start = now_ms(); + cwist_redis_reply_t *reply = NULL; + CHECK(CMD(p, 120, &reply, "ECHO", "timed") == -ETIMEDOUT); + double elapsed = now_ms() - start; + fprintf(stderr, " no wake pipe: %.1f ms\n", elapsed); + CHECK(elapsed >= 110 && elapsed < 200); + CHECK(reply == NULL); + pthread_join(thread, NULL); + cwist_free(job.out); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_reconnect_cached_peer(void) { + fake_server *s = server_start(); + atomic_store(&g_dns_calls, 0); + cwist_redis_pool_t *p = cwist_redis_pool_create("localhost", s->port, 2); + CHECK(p != NULL); + CHECK(atomic_load(&g_dns_calls) > 0); /* interposer is live */ + int base = wait_accepted(s, 2, 1000); + CHECK(base == 2); + cwist_redis_reply_t *r = NULL; + + /* From here on any resolver call fails and is counted. */ + atomic_store(&g_dns_calls, 0); + atomic_store(&g_dns_block, true); + + CHECK(CMD(p, 1000, &r, "REPLYCLOSE", "r1") == 0); + CHECK(is_bulk(r, "r1")); + reply_drop(r); + sleep_ms(50); /* peer FIN reaches the idle pooled connection */ + + for (int i = 0; i < 4; i++) CHECK(echo_ok(p, "x2")); + CHECK(wait_accepted(s, base + 1, 1000) == base + 1); + sleep_ms(30); + CHECK(atomic_load(&s->accepted) == base + 1); + CHECK(atomic_load(&g_dns_calls) == 0); + atomic_store(&g_dns_block, false); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_reconnect_refused_is_bounded(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + cwist_redis_reply_t *r = NULL; + CHECK(CMD(p, 1000, &r, "REPLYCLOSE", "bye") == 0); + reply_drop(r); + server_close_listener(s); + sleep_ms(50); + double t0 = now_ms(); + int c = CMD(p, 500, &r, "ECHO", "nobody"); + double el = now_ms() - t0; + CHECK(c == -EIO); + CHECK(r == NULL); + CHECK(el < 500); + fprintf(stderr, " refused reconnect: code %d after %.1f ms\n", c, el); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_unsolicited_bytes_never_leak(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + int base = wait_accepted(s, 1, 1000); + cwist_redis_reply_t *r = NULL; + + CHECK(CMD(p, 1000, &r, "UNSOL", "u1", ":666\r\n") == 0); + CHECK(is_bulk(r, "u1")); + reply_drop(r); + sleep_ms(80); /* unsolicited ":666" lands on the idle connection */ + CHECK(CMD(p, 1000, &r, "ECHO", "u2") == 0); + CHECK(is_bulk(r, "u2")); + reply_drop(r); + CHECK(wait_accepted(s, base + 1, 1000) == base + 1); + + /* Two replies for one command: the extra one must never be returned to a + * later caller. */ + CHECK(CMD(p, 1000, &r, "DOUBLE", "d1") == 0); + CHECK(is_bulk(r, "d1")); + reply_drop(r); + sleep_ms(30); + CHECK(CMD(p, 1000, &r, "ECHO", "d2") == 0); + CHECK(is_bulk(r, "d2")); + reply_drop(r); + CHECK(echo_ok(p, "d3")); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_malformed_replies(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + char deep[256] = {0}; + for (int i = 0; i < 20; i++) strcat(deep, "*1\r\n"); + strcat(deep, ":1\r\n"); + char longline[9100]; + longline[0] = '+'; + memset(longline + 1, 'a', 9000); + memcpy(longline + 9001, "\r\n", 3); + struct { + const char *bytes; + size_t len; + } cases[] = { + {"?x\r\n", 4}, + {":12a\r\n", 6}, + {":\r\n", 3}, + {": 1\r\n", 5}, + {":99999999999999999999\r\n", 23}, + {"$-2\r\n", 5}, + {"*-2\r\n", 5}, + {"$abc\r\n", 6}, + {"$2\r\nabXY", 8}, + {"*70000\r\n", 8}, + {"$999999999\r\n", 12}, + {deep, strlen(deep)}, + {longline, strlen(longline)}, + {"\r\n", 2}, + {":1\n", 3}, + {"+a\rb\r\n", 6}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + cwist_redis_reply_t *r = NULL; + double t0 = now_ms(); + int c = raw_cmd(p, 1000, cases[i].bytes, cases[i].len, &r, "RAW"); + double el = now_ms() - t0; + if (c != -EPROTO) fprintf(stderr, " malformed case %zu -> code %d\n", i, c); + CHECK(c == -EPROTO); + CHECK(r == NULL); + CHECK(el < 500); + if (r != &g_sentinel) reply_drop(r); + CHECK(echo_ok(p, "after-malformed")); + } + /* Peer EOF in the middle of a reply. */ + cwist_redis_reply_t *r = NULL; + CHECK(raw_cmd(p, 1000, "$5\r\nab", 6, &r, "RAWCLOSE") == -EIO); + CHECK(r == NULL); + CHECK(echo_ok(p, "after-eof")); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static void test_inherited_pool_rejected(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 1); + CHECK(p != NULL); + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + const void *argv[] = {"ECHO", "childtag"}; + size_t lens[] = {4, 8}; + cwist_redis_reply_t *r = &g_sentinel; + cwist_error_t e = cwist_redis_pool_command_argv_reply_timeout(p, 2, argv, lens, 200, &r); + _exit(ecode(e) == -EPERM && r == NULL ? 0 : 1); + } + int st = 0; + CHECK(pid > 0 && waitpid(pid, &st, 0) == pid); + CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0); + sleep_ms(50); + CHECK(log_count(s, "ECHO childtag", false) == 0); + CHECK(echo_ok(p, "parent-still-works")); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +typedef struct cc_job { + cwist_redis_pool_t *p; + int id; + int mismatches; + int errors; + int timeouts; +} cc_job; + +static void *cc_thread(void *arg) { + cc_job *j = arg; + char tag[32]; + for (int i = 0; i < 40; i++) { + snprintf(tag, sizeof(tag), "t%d-%d", j->id, i); + cwist_redis_reply_t *r = NULL; + if (i % 7 == 3) { + int c = CMD(j->p, 40, &r, "STALL", "200", tag); + if (c == -ETIMEDOUT && r == NULL) + j->timeouts++; + else + j->errors++; + if (c == 0) reply_drop(r); + } else { + int c = CMD(j->p, 3000, &r, "ECHO", tag); + if (c != 0) + j->errors++; + else if (!is_bulk(r, tag)) + j->mismatches++; + if (c == 0) reply_drop(r); + } + } + return NULL; +} + +static void test_concurrent_no_crosstalk(void) { + fake_server *s = server_start(); + cwist_redis_pool_t *p = cwist_redis_pool_create("127.0.0.1", s->port, 3); + CHECK(p != NULL); + enum { N = 6 }; + pthread_t t[N]; + cc_job jobs[N]; + for (int i = 0; i < N; i++) { + jobs[i] = (cc_job){.p = p, .id = i}; + pthread_create(&t[i], NULL, cc_thread, &jobs[i]); + } + int mism = 0, errs = 0, tos = 0; + for (int i = 0; i < N; i++) { + pthread_join(t[i], NULL); + mism += jobs[i].mismatches; + errs += jobs[i].errors; + tos += jobs[i].timeouts; + } + fprintf(stderr, " concurrent: mismatches %d errors %d timeouts %d\n", mism, errs, tos); + CHECK(mism == 0); + CHECK(errs == 0); + CHECK(tos == N * 6); + cwist_redis_pool_destroy(p); + server_stop(s); +} + +static const char *g_filter; + +#define RUN(fn) \ + do { \ + if (g_filter && !strstr(#fn, g_filter)) break; \ + int before_ = g_failures; \ + fprintf(stderr, "[redis-timeout] %s\n", #fn); \ + fn(); \ + fprintf(stderr, "[redis-timeout] %s: %s\n", #fn, g_failures == before_ ? "ok" : "FAILED"); \ + } while (0) + +/* Optional argv[1]: run only tests whose name contains it. */ +int main(int argc, char **argv) { + g_filter = argc > 1 ? argv[1] : NULL; + signal(SIGPIPE, SIG_DFL); + alarm(180); + dns_init(); + RUN(test_invalid_inputs); + RUN(test_typed_replies); + RUN(test_stall_deadline_and_stale_reply); + RUN(test_trickle_deadline); + RUN(test_write_deadline); + RUN(test_sigpipe_contained); + RUN(test_pool_saturation); + RUN(test_no_wake_pipe_deadline); + RUN(test_reconnect_cached_peer); + RUN(test_reconnect_refused_is_bounded); + RUN(test_unsolicited_bytes_never_leak); + RUN(test_malformed_replies); + RUN(test_inherited_pool_rejected); + RUN(test_concurrent_no_crosstalk); + if (g_failures) { + fprintf(stderr, "test_redis_timeout: %d check(s) failed\n", g_failures); + return 1; + } + printf("test_redis_timeout: all checks passed\n"); + return 0; +} From ae198886ca42988693a5f2d5d7fd4593ca002a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=A7=84=28Lee=20Yunjin=29?= <168180007+gg582@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:08:26 +0900 Subject: [PATCH 3/3] Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- scripts/ci/redis_rate_limit_e2e.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/redis_rate_limit_e2e.py b/scripts/ci/redis_rate_limit_e2e.py index a9f9f09db..1c92ef110 100755 --- a/scripts/ci/redis_rate_limit_e2e.py +++ b/scripts/ci/redis_rate_limit_e2e.py @@ -96,6 +96,7 @@ def run(binary, redis_server): try: os.killpg(test.pid, signal.SIGKILL) except ProcessLookupError: + # Process group already exited; nothing left to kill. pass test.wait() if log.exists():