From: Vsevolod Stakhov Date: Sat, 9 May 2026 13:38:18 +0000 (+0100) Subject: [Test] upstream: deterministic SRV rate-window test via libev fake clock X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7af41afd17da3d12fd679d54de3f84908f4d91f1;p=thirdparty%2Frspamd.git [Test] upstream: deterministic SRV rate-window test via libev fake clock Switch rspamd_upstream_fail's rate-window timestamp from rspamd_get_ticks(FALSE) to a new rspamd_upstream_now_fresh helper that calls ev_now_update_if_cheap then ev_now. Multiple fail() calls in a single loop iteration now see fresh times, and tests can drive virtual time through the libev hook without sleeping. * rspamd_upstream_now / rspamd_upstream_now_fresh helpers hoisted to the top of upstream.c with a short comment about why ev_now matters (loop-cached time = tests can drive it; production correctness wart of mixed time sources goes away). * rspamd_upstream_ctx_set_event_loop_for_test: install a loop on upstream_ctx without going through rspamd_upstreams_library_config (which needs a full rspamd_config). * rspamd_test::fake_clock RAII helper installs the libev hook, advances virtual time, and resyncs the loop on construct/destroy. The "error budget is per member" SRV test drops g_usleep(1000) and the error_time = 0.002 s macOS-jitter workaround; uses error_time = 1.0 s, max_errors = 4, and clk.advance(0.1) between fails. Test runs in 80 ms and is fully deterministic. --- diff --git a/src/libutil/upstream.c b/src/libutil/upstream.c index 5e4a6c38d4..8edd1506e1 100644 --- a/src/libutil/upstream.c +++ b/src/libutil/upstream.c @@ -315,6 +315,39 @@ static void rspamd_upstream_resolve_addrs(const struct upstream_list *ls, static void rspamd_upstream_set_inactive(struct upstream_list *ls, struct upstream *upstream); +/* + * Time helpers. We use ev_now() — the loop's cached time — wherever an event + * loop is available, so all decisions in a single loop iteration agree on + * "now" and tests can drive time deterministically via the libev fake-clock + * hook. The rspamd_get_ticks() fallback covers paths that may run before the + * event loop is wired up (early init, unit tests of pure helpers). + */ +static inline double +rspamd_upstream_now(const struct upstream *up) +{ + if (up->ctx && up->ctx->event_loop) { + return ev_now(up->ctx->event_loop); + } + return rspamd_get_ticks(FALSE); +} + +/* + * Same as rspamd_upstream_now() but first refreshes the loop's cached + * monotonic time. Use on rare paths (e.g. fail handling) where multiple + * timestamps may be sampled in a single loop iteration and we want each + * sample to reflect actual elapsed time. The "_if_cheap" variant only + * touches the monotonic clock; no realtime read. + */ +static inline double +rspamd_upstream_now_fresh(const struct upstream *up) +{ + if (up->ctx && up->ctx->event_loop) { + ev_now_update_if_cheap(up->ctx->event_loop); + return ev_now(up->ctx->event_loop); + } + return rspamd_get_ticks(FALSE); +} + void rspamd_upstreams_library_config(struct rspamd_config *cfg, struct upstream_ctx *ctx, struct ev_loop *event_loop, @@ -1190,6 +1223,13 @@ rspamd_upstream_srv_test_get_parent(struct upstream_list *ups) return NULL; } +void rspamd_upstream_ctx_set_event_loop_for_test(struct upstream_ctx *ctx, + struct ev_loop *event_loop) +{ + g_assert(ctx != NULL); + ctx->event_loop = event_loop; +} + void rspamd_upstream_member_force_alive_for_test(struct upstream *member, const char *ip_str) { @@ -1627,7 +1667,7 @@ void rspamd_upstream_fail(struct upstream *upstream, } if (upstream->ctx && upstream->active_idx != -1 && upstream->ls) { - sec_cur = rspamd_get_ticks(FALSE); + sec_cur = rspamd_upstream_now_fresh(upstream); RSPAMD_UPSTREAM_LOCK(upstream); if (upstream->errors == 0) { @@ -3458,15 +3498,6 @@ rspamd_upstream_refill_tokens(struct upstream *up, up->last_refill_at = now; } -static inline double -rspamd_upstream_now(const struct upstream *up) -{ - if (up->ctx && up->ctx->event_loop) { - return ev_now(up->ctx->event_loop); - } - return rspamd_get_ticks(FALSE); -} - struct upstream * rspamd_upstream_get_token_bucket(struct upstream_list *ups, struct upstream *except, diff --git a/src/libutil/upstream_internal.h b/src/libutil/upstream_internal.h index d9ff21826b..e77cb00dd7 100644 --- a/src/libutil/upstream_internal.h +++ b/src/libutil/upstream_internal.h @@ -74,6 +74,15 @@ void rspamd_upstream_member_force_alive_for_test(struct upstream *member, */ struct upstream *rspamd_upstream_srv_test_get_parent(struct upstream_list *ups); +/* + * Install an event loop on the context without going through + * rspamd_upstreams_library_config (which requires a full rspamd_config). + * Test-only: lets unit tests drive ev_now() and timer firing through the + * libev fake-clock hook (see ev.h: ev_set_fake_time_cb). + */ +void rspamd_upstream_ctx_set_event_loop_for_test(struct upstream_ctx *ctx, + struct ev_loop *event_loop); + #ifdef __cplusplus } #endif diff --git a/test/rspamd_cxx_unit_upstream_srv.hxx b/test/rspamd_cxx_unit_upstream_srv.hxx index 8431a9379d..350a2dca14 100644 --- a/test/rspamd_cxx_unit_upstream_srv.hxx +++ b/test/rspamd_cxx_unit_upstream_srv.hxx @@ -28,6 +28,8 @@ #include "libutil/upstream.h" #include "libutil/upstream_internal.h" +#include "rspamd_test_fake_time.hxx" +#include "contrib/libev/ev.h" #include #include @@ -40,10 +42,20 @@ struct ctx_holder { struct upstream_ctx *ctx; struct upstream_list *ups; struct upstream *parent; + struct ev_loop *loop; ctx_holder() { + /* + * Use a non-default loop so installing a fake clock in one test + * can't bleed into anything that touches the default loop. The + * loop never runs ev_run() in these tests; we only need it for + * ev_now() to read through to our fake-clock hook. + */ + loop = ev_loop_new(EVFLAG_AUTO); + REQUIRE(loop != nullptr); ctx = rspamd_upstreams_library_init(); + rspamd_upstream_ctx_set_event_loop_for_test(ctx, loop); ups = rspamd_upstreams_create(ctx); rspamd_upstreams_set_rotation(ups, RSPAMD_UPSTREAM_ROUND_ROBIN); @@ -61,6 +73,7 @@ struct ctx_holder { { rspamd_upstreams_destroy(ups); rspamd_upstreams_library_unref(ctx); + ev_loop_destroy(loop); } ctx_holder(const ctx_holder &) = delete; @@ -283,26 +296,21 @@ TEST_SUITE("upstream_srv") { ctx_holder t; /* - * Squeeze the error window so a few fails over a few tens of - * ms cross the rate threshold. Defaults (4 errors / 10s) would - * require multi-second sleeps to trigger in unit tests. - */ - /* - * Rate-based inactive transition fires when: - * (sec_cur - last_fail) >= error_time AND - * errors / elapsed > max_errors / error_time - * - * Pick aggressive limits so we comfortably exceed the threshold - * even with macOS scheduler jitter on g_usleep. + * Realistic defaults — the rate-window math is the same whether we + * run in real or fake time, so there's no need to squeeze error_time + * down to milliseconds. Five errors over a one-second budget with + * max_errors=4 puts us comfortably past the rate threshold. */ rspamd_upstreams_set_limits(t.ups, /* revive_time */ 60.0, /* revive_jitter */ 0.4, - /* error_time */ 0.002, + /* error_time */ 1.0, /* dns_timeout */ 1.0, - /* max_errors */ 1, + /* max_errors */ 4, /* dns_retransmits */ 2); + rspamd_test::fake_clock clk(1000.0, t.loop); + t.apply({ {"a.example.com", 11335, 1, 10}, {"b.example.com", 11335, 1, 10}, @@ -320,11 +328,12 @@ TEST_SUITE("upstream_srv") * Pre-refactor, the three SRV targets shared one error budget; * a burst here would have killed every target. With per-member * budgets, only `bad` crosses the rate threshold and exits the - * alive list. + * alive list. Each fail() advances virtual time by 100 ms so the + * rate-window arithmetic sees real elapsed time without sleeping. */ for (int i = 0; i < 12; i++) { rspamd_upstream_fail(bad, TRUE, "test"); - g_usleep(1000); /* 1 ms — gives ample margin over error_time=2ms */ + clk.advance(0.1); } /* diff --git a/test/rspamd_test_fake_time.hxx b/test/rspamd_test_fake_time.hxx new file mode 100644 index 0000000000..2aa7704528 --- /dev/null +++ b/test/rspamd_test_fake_time.hxx @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Vsevolod Stakhov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Test-only fake-clock helper. Drives ev_now() and timer firing in our + * bundled libev (see ev.h: ev_set_fake_time_cb / ev_now_resync) so unit + * tests can advance virtual time deterministically instead of sleeping. + * + * Process-global by design — the libev hook is process-wide. Use one + * fake_clock per scope, and don't run two in parallel within the same + * process. + */ + +#ifndef RSPAMD_TEST_FAKE_TIME_HXX +#define RSPAMD_TEST_FAKE_TIME_HXX + +#include "contrib/libev/ev.h" + +namespace rspamd_test { + +class fake_clock { +public: + /* Install the fake clock at `start` seconds. The optional `loop` is + * resynced so its cached realtime/monotonic state matches the new + * source — required when the loop was created before the hook was + * installed (otherwise ev_now() returns garbage from interpolation). + */ + explicit fake_clock(double start = 1000.0, struct ev_loop *loop = nullptr) + : now_(start), loop_(loop) + { + instance_ = this; + ev_set_fake_time_cb(&fake_clock::read); + if (loop_) { + ev_now_resync(loop_); + } + } + + ~fake_clock() + { + ev_set_fake_time_cb(nullptr); + instance_ = nullptr; + if (loop_) { + ev_now_resync(loop_); + } + } + + fake_clock(const fake_clock &) = delete; + fake_clock &operator=(const fake_clock &) = delete; + + /* Move time forward by `seconds`. Negative values intentionally + * unsupported: backward jumps would defeat libev's monotonic + * assumption and produce garbage timer behaviour. + */ + void advance(double seconds) + { + if (seconds < 0.0) { + seconds = 0.0; + } + now_ += seconds; + } + + void set(double t) + { + now_ = t; + } + + double now() const + { + return now_; + } + +private: + static double read() + { + return instance_ ? instance_->now_ : 0.0; + } + + double now_; + struct ev_loop *loop_; + + static inline fake_clock *instance_ = nullptr; +}; + +}// namespace rspamd_test + +#endif /* RSPAMD_TEST_FAKE_TIME_HXX */