fix: announce "up" only once redis can serve connections - #3
Conversation
RedisModule_OnLoad() ran send_udp_message() directly, and modules load during startup - before initListeners(). The broker therefore advertised itself over UDP while it was still refusing TCP connections. Discovery admits an announced host as a cluster member immediately, so clients dialled it and got ECONNREFUSED. A Kubernetes readiness probe does not gate this: the address clients dial is the pod IP carried in the datagram, not a Service endpoint. The first announcement now runs from a CronLoop hook that unsubscribes itself before announcing. Redis enters its event loop only after initListeners() and loadDataFromDisk(), so the first datagram cannot precede the listener, and a large RDB/AOF delays the announcement rather than letting it out early. Unsubscribing first means the hook cannot run twice and leaves no callback behind once it has done its one job. A one-shot module timer reaches the same boundary and was tried first, but Redis refuses MODULE UNLOAD while a module holds an unfired timer, so an immediate load/unload failed where it previously succeeded. A cron hook guarded by a flag was also tried: a function-local static survives this loaders
|
All contributors have signed the @imqueue Contribution Terms. ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
Thanks for the thorough write-up. The analysis in #2 holds on every point I checked against the Redis source, and the boundary this PR picks is the right one. The mechanism used to reach it is not safe, though, and it cannot be merged as written. Details, evidence and a proposed reshaping below. What holds up
The problem: unsubscribing from inside the hook is a use-after-free
el->module->in_hook++;
el->callback(&ctx,el->event,subid,moduledata);
el->module->in_hook--; /* el was freed by the callback */That read of What I ran, with this branch built from
Sanitizer report, trimmed: The shipped 8.0.5 binary crashes at This also explains why the harness in the description passed. The official Proposed reshapingKeep the cron boundary, never unsubscribe from inside the callback. Guard with a file-scope flag reset in static int announced = 0; /* file scope; reset in OnLoad so a reload announces again */
void cron_broadcast_once(RedisModuleCtx *ctx, RedisModuleEvent e,
uint64_t subevent, void *data) {
(void)e; (void)subevent; (void)data;
if (announced || is_closing) {
return;
}
announced = 1;
start_broadcasting(ctx);
}and next to the existing On the reason given for rejecting this shape: I could not reproduce a function-local Smaller items while you are in there:
Reporting the ReproductionThe probe I used, which is the subscribe/unsubscribe pattern and nothing else: #include "redismodule.h"
static void cron_once(RedisModuleCtx *ctx, RedisModuleEvent e, uint64_t sub, void *data) {
(void)e; (void)sub; (void)data;
if (RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_CronLoop, NULL) != REDISMODULE_OK) {
RedisModule_Log(ctx, "warning", "probe: could not remove the startup cron hook");
return;
}
RedisModule_Log(ctx, "notice", "probe: one-shot cron hook fired");
}
int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
(void)argv; (void)argc;
static int loads = 0;
if (RedisModule_Init(ctx, "probe", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) return REDISMODULE_ERR;
loads++;
RedisModule_Log(ctx, "notice", "probe: OnLoad, function-local static loads=%d", loads);
RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_CronLoop, cron_once);
return REDISMODULE_OK;
}# Redis 7.4.2 source tree; SANITIZER=address forces MALLOC=libc
make -C redis-7.4.2/src -j SANITIZER=address redis-server
gcc -fPIC -shared -O2 -o probe.so probe.c -I.
ASAN_OPTIONS=detect_leaks=0 redis-7.4.2/src/redis-server --port 16382 --save "" \
--enable-module-command yes --loadmodule ./probe.soThe report appears within the first cron tick. Loading this branch's |
Unsubscribing a server-event hook from inside its own callback is a use-after-free. RedisModule_SubscribeToServerEvent(ctx, event, NULL) frees the listener, and moduleFireServerEvent() then executes el->module->in_hook-- on that freed listener once the callback returns; the pattern is identical in redis 7.2, 7.4, 8.0, 8.2 and unstable. It stayed silent in the published redis images, whose bundled jemalloc keeps the module's allocations out of redis's heap, and crashed at the first cron tick in every shared-allocator build tried: a distro redis 7.0.15 linked to the system jemalloc segfaulted on 4/4 starts, a 7.4.2 built with libc malloc crashes on a garbage pointer, and an AddressSanitizer build reports the read in moduleFireServerEvent. The hook now stays subscribed for the life of the module. A file-scope flag makes the announcement one-shot and is reset in RedisModule_OnLoad() so a reloaded module announces again; redis drops the subscription itself on unload, in moduleUnregisterCleanup(). The cost is two integer checks per server cron. The reset matters on both loaders. On glibc, MODULE UNLOAD really unloads the object and static state starts over; on musl, the base of the shipped image, dlclose() never unloads and file-scope state survives a reload - without the reset the module announced on the first load only (8 datagrams, then 0 on each of four reloads). That is also what the earlier "a function-local static survives reload" observation was: musl behaviour, not a loader defect. Two adjacent corrections. The CronLoop registration in OnLoad is checked and logs a warning when it fails, since a broker that never announces is the one failure this module must not keep quiet about. RedisModule_OnUnload gets the signature redis actually calls, int (RedisModuleCtx *), returning REDISMODULE_OK; as a void function its answer was whatever the return register held, which redis reads as "refuse the unload" when it equals REDISMODULE_ERR. Verified against the unmodified module on the same harnesses as before: the announcement lands after "Ready to accept connections" on a patched redis and on the shipped image with a held listen(); immediate MODULE UNLOAD succeeds; five unload/reload cycles announce every time on musl and four loads announce four times on glibc; SIGTERM inside the pre-listener window sends nothing; cadence, interface selection, shutdown "down" messages and thread/fd counts are unchanged; 25 load/unload cycles leave no growth. The final module runs 8 s under AddressSanitizer without a report, twice, and survives 4/4 starts on the system-jemalloc redis where the previous commit crashed 4/4. End to end, the shipped image with this module served a real @imqueue/core client in 14/14 runs, including two broker swaps, every payload delivered exactly once.
|
@Mikhus Thanks for the thorough review — you're right, and the trace was exactly on point. I checked it independently before changing anything, and it reproduces cleanly. Why it crashed. Reproduced (
Follow-up commit, as you proposed:
On the Re-verified against the unmodified module: announce lands after Not in this PR, to keep it to one change: |
What does this PR do?
Closes #2
RedisModule_OnLoad()calledsend_udp_message()directly. Redis loads modulesduring start-up, before
initListeners(), so the broker advertised its addressover UDP while it was still refusing TCP connections. Discovery has no
serve-ability check — a host is admitted as a cluster member as soon as its
announcement arrives — so clients dialled it and were refused. A Kubernetes
readiness probe does not close this: the address clients dial is the pod IP
carried in the datagram, not a Service endpoint.
The first announcement now runs from a
CronLoophook that unsubscribes itselfbefore announcing. Redis enters its event loop only after
initListeners()andafter
loadDataFromDisk(), so the first datagram cannot precede the listener,and a large RDB/AOF delays the announcement rather than letting it out early.
Unsubscribing first means the hook cannot run twice and leaves no callback
behind once it has done its one job.
Nothing else changes: same per-interface threads, same cadence, same arguments.
global_redis_portandglobal_redis_tlsare resolved inOnLoadbefore thehook is registered, and the callback neither re-reads configuration nor derives
TLS state. The
announcing portlog line moves with the send, so it now reportsthe announcement rather than preceding it.
Why not a module timer
A one-shot
RedisModule_CreateTimer()reaches the same event-loop boundary andwas the first implementation, but redis refuses
MODULE UNLOADwhile a moduleholds an unfired timer, so an immediate load/unload failed where it previously
succeeded.
A cron hook guarded by a flag rather than unsubscribing was also tried. A
function-local
staticsurvives this loader's unload/reload, leaving a reloadedmodule permanently silent; a file-scope flag works but keeps a callback firing
for the life of the process. Unsubscribing needs neither.
The one cost: on a runtime
MODULE LOADthe announcement waits for the nextcron, about
1000 / hzms — roughly 100 ms at the defaulthz. At start-up thefirst cron is already due, so a
--loadmodulebroker is unaffected.Type of change
Checklist
npm test).This repository has no test suite or
package.json, so there is nothing torun — what was done instead is under How it was verified below.
How it was verified
Two harnesses. In both, the unmodified module was run first, on the same
harness, and had to reproduce the defect — otherwise the test cannot observe it
and its result means nothing.
sleep(5)beforeinitListeners(), giving aguaranteed interval in which the module has loaded but the server refuses
connections.
LD_PRELOADshim holding the firstlisten()on the redis port for 5 s, which widens the real window instead ofpatching redis.
Both run a stub Kubernetes API over TLS serving a pod list, so real datagrams
are emitted, and compare the first
upagainst redis's ownReady to accept connectionslog line — one clock, millisecond resolution.Checked against the unmodified module, all unchanged:
MODULE UNLOADimmediately afterMODULE LOADstill succeedsonce per load and thread count stays flat, so it neither goes silent nor
accumulates broadcasters
updatagrams and exitsaccumulates broadcasters
updatagrams and exitscleanly
SELECTED_INTERFACESunset announces every interface, a matching prefixannounces only that one, a non-matching prefix announces nothing
downemitted for every announced interface on shutdown; thread and fd countsidentical
MODULE LOAD/MODULE UNLOADleaves no thread or fd growth; the censusmoves 4 → 6 → 4 threads across a cycle, so it does observe the module's threads
Not covered
TLS-listener mode was not exercised end to end.
global_redis_tlsis assignedonce in
OnLoadbefore the hook is registered and passed through unchanged, sono code path makes it differ between the two call sites, and the emitted
payload's mode field was identical in every captured datagram.
Contribution terms (required)
I grant the project owner the right to license my contribution
commercially, royalty-free, my contribution stays available under
GPL-3.0, I keep my copyright, and I understand I will receive no fee for
it. If I did not agree, I would not be submitting this contribution.