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,
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)
{
}
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) {
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,
*/
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
#include "libutil/upstream.h"
#include "libutil/upstream_internal.h"
+#include "rspamd_test_fake_time.hxx"
+#include "contrib/libev/ev.h"
#include <map>
#include <set>
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);
{
rspamd_upstreams_destroy(ups);
rspamd_upstreams_library_unref(ctx);
+ ev_loop_destroy(loop);
}
ctx_holder(const ctx_holder &) = delete;
{
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},
* 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);
}
/*
--- /dev/null
+/*
+ * 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 */