Skip to main content

Multithread RDMA crash (lock-free IO queue model)

Author
quanye
Systems software: OS, networking, distributed systems.
Table of Contents

If it’s broken, I’ll fix it!

Reintroducing an interesting problem (background)
#

Description: This write-up fixes RDMA crashes under multithreading. While we work on it, main is also refactoring the I/O model — we do not yet know if the new model still hits this. (Verify later.)

We also need to understand the new I/O model (study PR history later; see Feature: Redis IO model).

Roughly:

flowchart LR
    Main["主线程"]
    IO["IO 线程"]
    Inbox["io_shared_inbox<br/>SPMC"]
    Outbox["io_shared_outbox<br/>MPSC"]

    Main -->|"JOB_REQ_READ/WRITE"| Inbox
    Inbox --> IO
    IO --> Outbox
    Outbox -->|"JOB_RES_*"| Main

Main’s I/O model has changed, so first verify whether the old RDMA multithread bug still exists under the new model. I opened a branch to check — it still fails. Good.

Testing under the new I/O model
#

At the old intensity, failures are rare; e.g. the following often does not trip:

./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma

Reproduction shows it still exists, but rarely? → crank up the load.

# -c 500 增加并发,-P 256 极限流水线,极容易打满 RDMA 的网卡队列
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 500 -P 256 -n 50000000 -t set,get --rdma

This mixed workload is brutal — under the new I/O model it should hard-fail, still with the c->cmd_queue.len == 0' is not true assertion.

That command is so heavy that even our current code I/O-hangs immediately. Proof the bug is still there. (Is this load “fair”? Our parameters are unbounded — must every arbitrary stress command “work”? Either way, assertions must not fire. Slow is OK; segfaults are not.)

  1. New I/O model: direct assertion failure
  2. Old model: can also deadlock (or I/O hang? park that for now.)
# -d 8192 甚至更高,测试大块数据在 RDMA 传输时是否会导致内存踩踏或分配器崩溃
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 8192 --threads 16 -c 200 -P 64 -n 10000000 -t set --rdma

This one is slow but eventually finishes — still smells wrong. Treat as OK for now. It no longer hangs forever — we are actually fixing things.

Keep trying to fix it
#

Build the server:

sudo prlimit --memlock=unlimited --pid $$
sudo modprobe dummy
sudo ip link add dummy0 type dummy
sudo ip link set dummy0 up
sudo ip addr add 10.0.0.1/24 dev dummy0
sudo rdma link add rxe_dummy type rxe netdev dummy0
sudo systemctl stop valkey
make -j$(nproc) BUILD_RDMA=yes USE_FAST_FLOAT=yes # 注意这里开启多线程的编译
sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379

Whenever the port is truly occupied:

sudo lsof -i :6379

lsof (List Open Files) lists files currently open on the system. On Linux “everything is a file”, including network sockets. -i :6379 filters connections on port 6379.

~/Project/valkey fix-rdma-assertion-new-io* ❯ sudo lsof -i :6379
COMMAND     PID USER FD   TYPE DEVICE SIZE/OFF NODE NAME
valkey-se 26355 root 8u  IPv4 148550      0t0  TCP *:redis (LISTEN)
~/Project/valkey fix-rdma-assertion-new-io* ❯ sudo kill 26355   

Client test:

sudo prlimit --memlock=unlimited --pid $$
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 500 -P 256 -n 50000000 -t set,get --rdma

And our valkey.conf looks like this. Indeed single-thread is fine; only with multiple IO threads does it fail.

io-threads 4
save ""
protected-mode no
bind 0.0.0.0

And we can still reproduce the old error stably.

Detailed discussion
#

First deeply understand how IO moves client work — Feature: Redis IO model We must deeply understand how to fix this — our first deep technical discussion with pizhenwei. I drew an architecture diagram to pin the bug — proud of it (even if it looks ugly).

First declare / sort a few variables:

State machine Meaning
io_read_state / io_write_state offload lifecycle: IDLE → PENDING_IO → COMPLETED_IO → IDLE
cmd_queue Pipeline commands already parsed by IO, not yet executed by main → that is what parsing feeds

The assertion means: do not parse new commands before prior ones have been executed.

RDMA quirk: no standalone POLLOUT; connUpdateState() → connRdmaEventHandler() sync-calls read_handler / write_handler.

While fixing, GET also hits an access-nil error
#

Multithread bugs are truly complex — too hard.

The error log is huge — put it in a file to analyze; most lines are noise, but then you lose the earliest error.

sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379 > valkey-run.log 2>&1

Maybe related to the old thread model in Multithread RDMA crash — keep digging. That engineer is sharp, but this looks like a different issue. Use:

sudo prlimit --memlock=unlimited --pid $$
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 500 -P 256 -n 50000000 -t set,get --rdma

Under load, entering the GET phase, Ctrl+C always crashes the server — another serious bug. How do you catch such a crash — whose call path blew up? First check whether the server binary has debug symbols:

~/Project/valkey fix-rdma-assertion-new-io* ❯ file ./src/valkey-server
./src/valkey-server: ELF 64-bit LSB pie executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=afbcceb012f03c869136fe49f6f71261a65185af, for GNU/Linux 4.4.0, with debug_info, not stripped

Then inside gdb:

~/Project/valkey fix-rdma-assertion-new-io* ❯ sudo gdb --args ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379

Start the server first:

set pagination off
handle SIGPIPE nostop noprint
run

After it crashes:

thread apply all bt
bt
frame 1
info frame

Inspect thread frames to see who called into the access-nil path. Very satisfying — you see exactly where it blew:

Thread 1 (Thread 0x7ffff7c90740 (LWP 42401) "valkey-server"):
#0  listUnlinkNode (list=0x7ffff743d420, node=0x0) at /home/ada/Project/valkey/src/adlist.c:194
#1  0x000055555570ef56 in listDelNode (list=0x7ffff743d420, node=0x0) at /home/ada/Project/valkey/src/adlist.c:183
#2  rdmaProcessPendingData () at /home/ada/Project/valkey/src/rdma.c:1785
#3  0x0000555555747f93 in connTypeProcessPendingData () at /home/ada/Project/valkey/src/connection.c:147
#4  beforeSleep (eventLoop=<optimized out>) at /home/ada/Project/valkey/src/server.c:1878
#5  beforeSleep (eventLoop=<optimized out>) at /home/ada/Project/valkey/src/server.c:1842
#6  0x00005555555fa1df in aeProcessEvents (flags=27, eventLoop=0x7ffff744d000) at /home/ada/Project/valkey/src/ae.c:426
#7  aeMain (eventLoop=0x7ffff744d000) at /home/ada/Project/valkey/src/ae.c:543
#8  0x00005555555e94fb in main (argc=6, argv=0x7fffffffe298) at /home/ada/Project/valkey/src/server.c:7795

Reading bottom-up shows the call chain clearly — a sharp analysis tool. When node=0x0 above, that is the failure. beforeSleep → connTypeProcessPendingData → rdmaProcessPendingDatardma.c:1785)→ listDelNode(pending_list, rdma_conn->pending_list_node) → listUnlinkNode, with node == NULL. Multiple frees caused this.

Full circle: this is the same Ctrl+C server crash we fixed in Valkey Over RDMA: server UAF in tests Closed the loop — merging the latest code should kill it: link. Read the fix rationale. Tests no longer hit it — excellent.

Polish the details
#

Looks fixed, but details still need polishing. After the first change we noticed something interesting about this mixed command set:

./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 500 -P 256 -n 50000000 -t set,get --rdma

It should do many SETs first, then many GETs. Before reordering, SET was fine, but the following GET failed — another issue I claimed: https://github.com/valkey-io/valkey/issues/3356 So keep fixing on top of that.

One unclear point: can our new-I/O-model changes apply directly to the old branch, so we do not need two PRs — just keep patching the original? We know the approach; we need a cleaner way.

Probably not — latest unstable and the old tree need different techniques. Try experimental patches first; hard; wait for review comments before pushing further. Land one version for now; revisit if problems remain.

Glance at the new IO model
#

Feature: Redis IO model — here IO threads go from passive to active Architecture comparison Old architecture (polling): Main IO1 IO2 IO3

–[job]–>[queue1]——>
–[job]–>[queue2]——>———–>
–[job]–>[queue3]——>———————>
<-[poll all clients]<-
(inefficient, wastes CPU)

New architecture (event-driven): Main IO1 IO2 IO3

↓ ↓ ↓
–[job]–>[shared SPMC queue]←-compete pull-
(auto load balance)
<—[MPSC response queue]<—–+—-+—-+
(fast notify, no polling)

Still seeing IO hang
#

Do not work around it — if it can hang, the change itself is wrong. htop — inspect memory/CPU; where is the problem? perf — once you have a pid, find hot functions and why it stopped there. Still want to check whether old logic can apply directly. Still cannot find why it hangs.

Record the hang-fix process
#

Currently hangs intermittently. Symptoms:

  1. Usually two CPU cores spin.
  2. After it ends, restarting benchmark hangs immediately again.

Client or server stuck?
#

Use strace — first see what benchmark threads are doing:

pid="$(pgrep -nf valkey-benchmark)"
sudo timeout 15 strace -c -f -p "$pid"

timeout 15 stops after 15s; with per-thread stats you see:

~/Project/valkey fix-rdma-assertion-new-io* ❯ pid="$(pgrep -nf valkey-benchmark)"
sudo timeout 15 strace -c -f -p "$pid"
strace: Process 64770 attached with 17 threads
strace: Process 64791 detached
strace: Process 64793 detached
strace: Process 64792 detached
strace: Process 64790 detached
strace: Process 64789 detached
strace: Process 64788 detached
strace: Process 64787 detached
strace: Process 64786 detached
strace: Process 64785 detached
strace: Process 64784 detached
strace: Process 64783 detached
strace: Process 64782 detached
strace: Process 64781 detached
strace: Process 64780 detached
strace: Process 64779 detached
strace: Process 64778 detached
strace: Process 64770 detached
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 99.89    0.233949         244       956           epoll_wait
  0.11    0.000252           4        59           write
------ ----------- ----------- --------- --------- ----------------
100.00    0.234201         230      1015           total

Benchmark threads all sit in epoll_wait — so the server RDMA path is stuck and stopped replying.

What is the server doing?
#

PID=64539
top - 23:06:29 up  9:08,  1 user,  load average: 2.75, 2.84, 2.81
Threads: 13 total, 2 running, 11 sleep, 0 d-sleep, 0 stopped, 0 zombie
%Cpu(s):  7.7 us,  0.5 sy,  0.0 ni, 91.1 id,  0.5 wa,  0.2 hi,  0.2 si,  0.0 st
MiB Mem :  15673.7 total,   1471.2 free,  12377.8 used,   4286.4 buff/cache
MiB Swap:   4096.0 total,   2244.7 free,   1851.3 used.   3295.9 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  64539 root      20   0 2946372   2.6g   1.5g R  99.3  16.9  40:12.97 valkey-+
  64545 root      20   0 2946372   2.6g   1.5g R  99.3  16.9  40:16.35 io_thd_1
  64540 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 bio_clo+
  64541 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 bio_aof
  64542 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 bio_laz+
  64543 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 bio_rdb+
  64544 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 bio_tls+
  64546 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:06.25 io_thd_2
  64547 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:05.07 io_thd_3
  64548 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 jemallo+
  64549 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 jemallo+
  64550 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 jemallo+
  64551 root      20   0 2946372   2.6g   1.5g S   0.0  16.9   0:00.00 jemallo+                                                      

You should see one main-thread CPU and one IO thread busy-waiting — mainly those two.

Kernel stack snapshot without GDB
#

sudo cat /proc/$pid/stack
for tid in /proc/$pid/task/*; do
  echo "=== $(basename $tid) ==="
  sudo cat $tid/stack 2>/dev/null | head -20
done | less

Walk all tids under the pid and dump stacks. But 64539 and 64545 were not captured above?

Then snapshot with GDB
#

~/Project/valkey fix-rdma-assertion-new-io* ❯ sudo gdb -p "$pid" -batch \
  -ex "set pagination off" \
  -ex "thread apply all bt" \
  -ex "detach" \
  -ex "quit" \
  2>&1 | tee /tmp/valkey-hang-bt.txt

# lightweight process 轻量级进程:这12个是子进程 最后面的64539是主进程
[New LWP 64551]
[New LWP 64550]
[New LWP 64549]
[New LWP 64548]
[New LWP 64547]
[New LWP 64546]
[New LWP 64545]
[New LWP 64544]
[New LWP 64543]
[New LWP 64542]
[New LWP 64541]
[New LWP 64540]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
postponeClientRead (c=0x7f00c6b68e80) at /home/ada/Project/valkey/src/networking.c:6409
6409        if (ProcessingEventsWhileBlocked) return 0;

Thread 13 (Thread 0x7f00ff7326c0 (LWP 64540) "bio_close_file"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f21b00b in mutexQueuePop (theQueue=0x7f0100852380, blocking=<optimized out>) at /home/ada/Project/valkey/src/mutexqueue.c:123                                                                            
#5  0x0000558c5f15ecee in bioProcessBackgroundJobs (arg=0x558c5f4f1280 <bio_workers.lto_priv>) at /home/ada/Project/valkey/src/bio.c:269                                                                                
#6  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#7  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 12 (Thread 0x7f00fef316c0 (LWP 64541) "bio_aof"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f21b00b in mutexQueuePop (theQueue=0x7f01008523f0, blocking=<optimized out>) at /home/ada/Project/valkey/src/mutexqueue.c:123                                                                            
#5  0x0000558c5f15ecee in bioProcessBackgroundJobs (arg=0x558c5f4f1298 <bio_workers.lto_priv+24>) at /home/ada/Project/valkey/src/bio.c:269                                                                             
#6  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#7  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 11 (Thread 0x7f00fe7306c0 (LWP 64542) "bio_lazy_free"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f21b00b in mutexQueuePop (theQueue=0x7f0100852460, blocking=<optimized out>) at /home/ada/Project/valkey/src/mutexqueue.c:123                                                                            
#5  0x0000558c5f15ecee in bioProcessBackgroundJobs (arg=0x558c5f4f12b0 <bio_workers.lto_priv+48>) at /home/ada/Project/valkey/src/bio.c:269                                                                             
#6  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#7  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 10 (Thread 0x7f00fdf2f6c0 (LWP 64543) "bio_rdb_save"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f21b00b in mutexQueuePop (theQueue=0x7f01008524d0, blocking=<optimized out>) at /home/ada/Project/valkey/src/mutexqueue.c:123                                                                            
#5  0x0000558c5f15ecee in bioProcessBackgroundJobs (arg=0x558c5f4f12c8 <bio_workers.lto_priv+72>) at /home/ada/Project/valkey/src/bio.c:269                                                                             
#6  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#7  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 9 (Thread 0x7f00fd72e6c0 (LWP 64544) "bio_tls_reload"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f21b00b in mutexQueuePop (theQueue=0x7f0100852540, blocking=<optimized out>) at /home/ada/Project/valkey/src/mutexqueue.c:123                                                                            
#5  0x0000558c5f15ecee in bioProcessBackgroundJobs (arg=0x558c5f4f12e0 <bio_workers.lto_priv+96>) at /home/ada/Project/valkey/src/bio.c:269                                                                             
#6  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#7  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 8 (Thread 0x7f00fcf2d6c0 (LWP 64545) "io_thd_1"):
#0  0x0000558c5f21a612 in __rdtsc () at /usr/lib/gcc/x86_64-pc-linux-gnu/15.2.1/include/ia32intrin.h:114
#1  getMonotonicUs_x86 () at /home/ada/Project/valkey/src/monotonic.c:46
#2  0x0000558c5f1dfa66 in IOThreadMain (myid=<optimized out>) at /home/ada/Project/valkey/src/io_threads.c:314                                                                                                          
#3  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#4  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 7 (Thread 0x7f00fc72c6c0 (LWP 64546) "io_thd_2"):
#0  0x00007f0100c938d0 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9a0c4 in pthread_mutex_lock () from /usr/lib/libc.so.6
#2  0x0000558c5f1dfbf3 in IOThreadMain (myid=<optimized out>) at /home/ada/Project/valkey/src/io_threads.c:384                                                                                                          
#3  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#4  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 6 (Thread 0x7f00fbf2b6c0 (LWP 64547) "io_thd_3"):
#0  0x00007f0100c938d0 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9a0c4 in pthread_mutex_lock () from /usr/lib/libc.so.6
#2  0x0000558c5f1dfbf3 in IOThreadMain (myid=<optimized out>) at /home/ada/Project/valkey/src/io_threads.c:384                                                                                                          
#3  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#4  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 5 (Thread 0x7f00fb72a6c0 (LWP 64548) "jemalloc_bg_thd"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f34a8f5 in background_thread_sleep (tsdn=<optimized out>, info=<optimized out>, interval=<optimized out>) at src/background_thread.c:137                                                                 
#5  background_work_sleep_once (tsdn=<optimized out>, info=<optimized out>, ind=0) at src/background_thread.c:229                                                                                                       
#6  background_thread0_work (tsd=<optimized out>) at src/background_thread.c:374
#7  background_work (tsd=<optimized out>, ind=<optimized out>) at src/background_thread.c:412
#8  background_thread_entry (ind_arg=<optimized out>) at src/background_thread.c:444
#9  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#10 0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 4 (Thread 0x7f00fa7ff6c0 (LWP 64549) "jemalloc_bg_thd"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f34a239 in background_thread_sleep (tsdn=<optimized out>, info=0x7f0100a16950, interval=<optimized out>) at src/background_thread.c:137                                                                  
#5  background_work_sleep_once (tsdn=<optimized out>, info=<optimized out>, ind=<optimized out>) at src/background_thread.c:229                                                                                         
#6  background_work (tsd=<optimized out>, ind=<optimized out>) at src/background_thread.c:419
#7  background_thread_entry (ind_arg=<optimized out>) at src/background_thread.c:444
#8  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#9  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 3 (Thread 0x7f00f9ffe6c0 (LWP 64550) "jemalloc_bg_thd"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f34a239 in background_thread_sleep (tsdn=<optimized out>, info=0x7f0100a16a20, interval=<optimized out>) at src/background_thread.c:137                                                                  
#5  background_work_sleep_once (tsdn=<optimized out>, info=<optimized out>, ind=<optimized out>) at src/background_thread.c:229                                                                                         
#6  background_work (tsd=<optimized out>, ind=<optimized out>) at src/background_thread.c:419
#7  background_thread_entry (ind_arg=<optimized out>) at src/background_thread.c:444
#8  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#9  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 2 (Thread 0x7f00f93ff6c0 (LWP 64551) "jemalloc_bg_thd"):
#0  0x00007f0100c9ef32 in ?? () from /usr/lib/libc.so.6
#1  0x00007f0100c9339c in ?? () from /usr/lib/libc.so.6
#2  0x00007f0100c9368c in ?? () from /usr/lib/libc.so.6
#3  0x00007f0100c95e5e in pthread_cond_wait () from /usr/lib/libc.so.6
#4  0x0000558c5f34a239 in background_thread_sleep (tsdn=<optimized out>, info=0x7f0100a16af0, interval=<optimized out>) at src/background_thread.c:137                                                                  
#5  background_work_sleep_once (tsdn=<optimized out>, info=<optimized out>, ind=<optimized out>) at src/background_thread.c:229                                                                                         
#6  background_work (tsd=<optimized out>, ind=<optimized out>) at src/background_thread.c:419
#7  background_thread_entry (ind_arg=<optimized out>) at src/background_thread.c:444
#8  0x00007f0100c9697a in ?? () from /usr/lib/libc.so.6
#9  0x00007f0100d1a2bc in ?? () from /usr/lib/libc.so.6

Thread 1 (Thread 0x7f0100e32740 (LWP 64539) "valkey-server"):
#0  postponeClientRead (c=0x7f00c6b68e80) at /home/ada/Project/valkey/src/networking.c:6409
#1  readQueryFromClient (conn=0x7f00f3035f40) at /home/ada/Project/valkey/src/networking.c:4256
#2  0x0000558c5f260a9a in callHandler (conn=0x7f00f3035f40, handler=<optimized out>) at /home/ada/Project/valkey/src/connhelpers.h:79                                                                                   
#3  connRdmaEventHandler (el=<optimized out>, fd=<optimized out>, clientData=0x7f00f3035f40, mask=<optimized out>) at /home/ada/Project/valkey/src/rdma.c:735                                                           
#4  0x0000558c5f22d3b5 in connUpdateState (conn=<optimized out>) at /home/ada/Project/valkey/src/connection.h:524                                                                                                       
#5  processClientIOWriteDone (c=0x7f00c6b68e80) at /home/ada/Project/valkey/src/networking.c:3215
#6  processClientIOWriteDone (c=0x7f00c6b68e80) at /home/ada/Project/valkey/src/networking.c:3204
#7  0x0000558c5f1dfec7 in handleWriteJobs (write_jobs=0x7fff24438e80, write_count=<optimized out>) at /home/ada/Project/valkey/src/io_threads.c:884                                                                     
#8  processIOThreadsResponses () at /home/ada/Project/valkey/src/io_threads.c:934
#9  0x0000558c5f29d228 in processIOThreadsResponses () at /home/ada/Project/valkey/src/io_threads.c:73
#10 beforeSleep (eventLoop=<optimized out>) at /home/ada/Project/valkey/src/server.c:1975
#11 beforeSleep (eventLoop=<optimized out>) at /home/ada/Project/valkey/src/server.c:1842
#12 0x0000558c5f14f1df in aeProcessEvents (flags=27, eventLoop=0x7f010084d000) at /home/ada/Project/valkey/src/ae.c:426                                                                                                 
#13 aeMain (eventLoop=0x7f010084d000) at /home/ada/Project/valkey/src/ae.c:543
#14 0x0000558c5f13e4fb in main (argc=6, argv=0x7fff24439388) at /home/ada/Project/valkey/src/server.c:7795
[Inferior 1 (process 64539) detached]

Read bottom-up (from outer frames into the core). At #0 the thread stopped here:

Try to fix the root cause: It is a state-machine misalignment, already written into this fix busy-spin commit message — reread. Classic state-machine confusion.

Semantic-level refactor
#

What refactor approaches do we have?

Root cause: before offload, set postpone; after finish, clear it; then updateEvent.

Unused logic: postpone_update_state has an unused parameter. Triggered from two paths — maybe usable. Normal events only enter on POLLIN (only POLLIN registered?). Mask only allows POLLIN in. When we enter via the handler above, other parameters can control whether read/write handlers run; with mask we know the trigger path: either system (RDMA CQ) POLLIN via aeEvent — mask is only POLLIN — or when postpone clears and updateState runs, use a dedicated mask on that path and see how the error fires. Or reserve a mask bit for internal use — decide whether to wake specific handlers. Might fix it. Avoid our extra int flag; postpone came from TLS; RDMA does not want to dirty networking, but worth trying.

Walk the root cause again
#

Walk the code once more. Mindset while fixing: main and IO threads communicate via lock-free queues. Root cause is clear; what remains is whether the fix is elegant.

1. Main-thread state
#

Start from the read callback (reentrancy is in the read path): readQueryFromClient:

void readQueryFromClient(connection *conn) {
	client *c = connGetPrivateData(conn);
	/* Check if we can send the client to be handled by the IO-thread */
	// 尝试能否推迟ClientRead
	if (postponeClientRead(c)) return;
	if (c->io_write_state != CLIENT_IDLE || c->io_read_state != CLIENT_IDLE) return;
	......
	}

Calls postponeClientRead: (postpone = can we hand this to an IO thread for now)

/* Return 1 if the client read is handled using threaded I/O.
* 0 otherwise. */
int postponeClientRead(client * c) {
    if (ProcessingEventsWhileBlocked) return 0;
    // 尝试看这个client read是否能够派发给IO线程来进行处理。
    return (trySendReadToIOThreads(c) == C_OK);
}

Then trySendReadToIOThreads, matching the comments:

int trySendReadToIOThreads(client * c) {
	// 这里有自己的计算方法
    if (server.active_io_threads_num <= 1) return C_ERR;
    /* Fake/teardown clients may have no connection; never offload those. */
    if (!c - > conn) return C_ERR;
    /* If IO thread is still reading, return C_OK so the main thread does not race it. */
    if (c - > io_read_state == CLIENT_PENDING_IO) return C_OK;
    /* A completed read must be finished by processClientIOReadsDone on the main thread
     * before we try to offload another read; do not treat it like PENDING_IO. */
       
    // 这里返回C_ERR来解决状态机紊乱的问题。 目前的状态是C
    
    if (c - > io_read_state == CLIENT_COMPLETED_IO) return C_ERR;
    if (c - > io_write_state == CLIENT_PENDING_IO) return C_OK;
    /* For simplicity, don't offload replica clients reads as read traffic from replica is negligible */
    if (getClientType(c) == CLIENT_TYPE_REPLICA) return C_ERR;
    /* With Lua debug client we may call connWrite directly in the main thread */
    if (c - > flag.lua_debug) return C_ERR;
    /* For simplicity let the main-thread handle the blocked clients */
    if (c - > flag.blocked || c - > flag.unblocked) return C_ERR;
    if (c - > flag.close_asap) return C_ERR;

    c - > read_flags = canParseCommand(c) ? 0 : READ_FLAGS_DONT_PARSE;
    c - > read_flags |= authRequired(c) ? READ_FLAGS_AUTH_REQUIRED : 0;
    c - > read_flags |= isReplicatedClient(c) ? READ_FLAGS_REPLICATED : 0;
    c - > io_read_state = CLIENT_PENDING_IO;
    connSetPostponeUpdateState(c - > conn, 1);
	
	// 单生产者多消费者队列
    if (unlikely(spmcEnqueue( & io_shared_inbox, tagJob(c, JOB_REQ_READ_CLIENT)) == false)) {
        c - > read_flags = 0;
        c - > io_read_state = CLIENT_IDLE;
        connSetPostponeUpdateState(c - > conn, 0);
        return C_ERR;
    }

	// 此时真的offload当前这个readjob
    io_jobs_submitted++;
    server.stat_io_reads_pending++;
    c - > flag.pending_read = 1;
    return C_OK;
}

After a successful return, this client read should already be offloaded to an IO thread. Note two points (keep analyzing main on this line; or jump to client code below):

c -> io_read_state = CLIENT_PENDING_IO;
connSetPostponeUpdateState(c -> conn, 1);

We set the first read state machine to CLIENT_PENDING_IO, and call connSetPostponeUpdateState; if that function pointer is registered, we invoke the callback:

static inline void connSetPostponeUpdateState(connection *conn, int on) {
	if (conn->type->postpone_update_state) {
		conn->type->postpone_update_state(conn, on);
	}
}

The callback pointer is defined in connection.c:

typedef struct connectionType {
	/* Postpone update state - with IO threads & TLS we don't want the IO threads to update the event loop events - let
* the main-thread do it */
  // 这个回调指针是TLS引入的,就是说在TLS连接层和开启IO线程的情况下,我们不希望IO线程来更新 事件循环事件(这是什么?)而是使用主线程来进行更新。
	void (*postpone_update_state)(struct connection *conn, int);
}

What does postpone_update_state do? Only RDMA and TLS use this callback:

static ConnectionType CT_RDMA = {
......
.postpone_update_state = postPoneUpdateRdmaState, 
......
}

That is the concrete function — look at postPoneUpdateRdmaState:

static void postPoneUpdateRdmaState(struct connection *conn, int postpone) {
rdma_connection *rdma_conn = (rdma_connection *)conn;
if (postpone) {
	rdma_conn->flags |= RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE;
} else {
	rdma_conn->flags &= ~RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE;
}
}

If postpone is needed (nonzero arg → set RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE, else clear it). Where is the flag used? Within this connection we can always operate on it. (Actually used after IO’s sendToMainThread, so continue from _main receiving IO replies.)

#define RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE (1 << 0)

The define above is 1.

============ divider ============

_main after IO returns results
#

beforeSleep runs each round before 「preparing to block waiting for I/O」: Readable/writable callbacks from the previous poll have finished; beforeSleep finishes this phase’s bookkeeping, then enters poll (or custompoll / IO-thread poll).

Watch processIOThreadsResponses() inside beforeSleep: What is it doing? Pull completed read/write/accept results from IO threads back to main: advance networking/client state.

int processIOThreadsResponses(void) {

    /* We don't check for threads number since some threads may return jobs then deactivate/shut-down */
    /* Quick check if any pending operations exist */
    if (getPendingIOResponsesCount() == 0) return 0;
    int total_processed = 0;
    void * jobs[JOB_BATCH_SIZE];
    client * read_jobs[JOB_BATCH_SIZE];
    client * write_jobs[JOB_BATCH_SIZE];

    /* Loop until we consume all pending jobs */
    // 循环遍历处理之前IO线程返回的每一个JOB
    while (1) {
        int received_responses = 0;
        int dequeued_count = 0;
        int read_count = 0;
        int write_count = 0;
        /* Try to dequeue JOB_BATCH_SIZE */
        while (received_responses < JOB_BATCH_SIZE) {

            // 从多生产者单消费者队列中取jobs进行处理
            // 批量进行出队的操作
            dequeued_count = mpscDequeueBatch( & io_shared_outbox, jobs, JOB_BATCH_SIZE - received_responses);
            /* Stop if we can't get more jobs from the queue. */

            if (dequeued_count == 0) break;
            received_responses += dequeued_count;
            total_processed += dequeued_count;
            
            for (int i = 0; i < dequeued_count; i++) {
                void * data;
                int job_type;
                // 这里把client给解出来
                untagJob(jobs[i], & data, & job_type);
                client * c = (client * ) data;
                
                if (job_type == JOB_RES_READ_CLIENT) {
                
                    // 此时的状态必须是IO处理结束的
                    serverAssert(c - > io_read_state == CLIENT_COMPLETED_IO);
                    read_jobs[read_count++] = c;
                } else if (job_type == JOB_RES_WRITE_CLIENT) {
                
                    serverAssert(c - > io_write_state == CLIENT_COMPLETED_IO);
                    write_jobs[write_count++] = c;
                } else {
                    serverPanic("Unknown job type %d", job_type);
                }
            }
        }
        // 对于要读和要写的操作调用不同的函数。
        if (read_count) handleReadJobs(read_jobs, read_count);
        if (write_count) handleWriteJobs(write_jobs, write_count);
        /* If the queue was empty at the last try - don't try again */
        if (dequeued_count == 0) return total_processed;
    }
}

It walks many jobs; IO enqueued read jobs (our whole path is read analysis); for the collected read-jobs array call handleReadJobs; look at that function (unmodified version — we are still sorting root cause):

/* Function to handle read jobs */
static void handleReadJobs(client ** read_jobs, int read_count) {
	// 我们这次处理了这么多读任务
    server.stat_io_reads_pending -= read_count;
    serverAssert(server.stat_io_reads_pending >= 0);

    /* process each client */
    // 遍历每个client客户端
    for (int i = 0; i < read_count; i++) {
        client * c = read_jobs[i];
        processClientIOReadsDone(c);
    }

    /* Process commands in batch if we processed any reads */
    if (read_count) {
        server.stat_io_reads_processed += read_count;
        processClientsCommandsBatch();
    }
}

Old logic: walk each collected client, then processClientIOReadsDone — what does that do?

This should already be the wrap-up.

void processClientIOReadsDone(client * c) {
    serverAssert(c - > io_read_state == CLIENT_COMPLETED_IO);

    if (ProcessingEventsWhileBlocked) {
        /* When ProcessingEventsWhileBlocked we may call processIOThreadsReadDone recursively.
         * In this case, there may be some clients left in the batch waiting to be processed. */
        processClientsCommandsBatch();
    }

    c - > flag.pending_read = 0;
    c - > io_read_state = CLIENT_IDLE;
    /* Don't post-process-reads from clients that are going to be closed anyway. */
    if (c - > flag.close_asap) return;
    /* If a client is protected, don't do anything,
     * that may trigger read/write error or recreate handler. */
    if (c - > flag.protected) return;

    /* Save the current conn state, as connUpdateState may modify it */
    int in_accept_state = (connGetState(c - > conn) == CONN_STATE_ACCEPTING);
    connSetPostponeUpdateState(c - > conn, 0);
    connUpdateState(c - > conn);

    /* In accept state, no client's data was read - stop here. */
    if (in_accept_state) return;
    /* On read error - stop here. */
    if (handleReadResult(c) == C_ERR) {
        return;
    }

    if (!(c - > read_flags & READ_FLAGS_DONT_PARSE)) {
        parseResult res = handleParseResults(c);
        /* On parse error - stop here. */
        if (res == PARSE_ERR) {
            return;
        } else if (res == PARSE_NEEDMORE) {
            beforeNextClient(c);
            return;
        }
    }
    if (c - > argc > 0) {
        c - > flag.pending_command = 1;
    }
    /* try to add the command to the batch */
    int ret = addCommandToBatchAndProcessIfFull(c);
    /* If the command was not added to the commands batch, process it immediately */
    if (ret == C_ERR) {
        if (processPendingCommandAndInputBuffer(c) == C_OK) beforeNextClient(c);
    }
}

Note: client state becomes IDLE — idle again, reset.

  c - > flag.pending_read = 0;
  c - > io_read_state = CLIENT_IDLE;
  /* Don't post-process-reads from clients that are going to be closed anyway. */
  if (c - > flag.close_asap) return;

State update — this is where it breaks:

    /* Save the current conn state, as connUpdateState may modify it */
    int in_accept_state = (connGetState(c - > conn) == CONN_STATE_ACCEPTING);
    connSetPostponeUpdateState(c - > conn, 0);
    connUpdateState(c - > conn);

After finishing, we restore the postponeUpdateState function pointer. In processClientIOReadsDone we update state via RDMA’s connUpdateState:

static inline void connUpdateState(connection *conn) {
	if (conn->type->update_state) {
		conn->type->update_state(conn);
	}
}

That calls the registered update_state callback:

static ConnectionType CT_RDMA = {
......
.postpone_update_state = postPoneUpdateRdmaState,
.update_state = updateRdmaState,
......
};

Look at updateRdmaState:

static void updateRdmaState(struct connection * conn) {
    rdma_connection * rdma_conn = (rdma_connection * ) conn;
    connRdmaSetRwHandler(conn);
    connRdmaEventHandler(NULL, -1, rdma_conn, 0);
}

connRdmaSetRwHandler only registers the readable handler — IB can only register POLLIN. Then connRdmaEventHandler runs:

static void connRdmaEventHandler(struct aeEventLoop * el, int fd, void * clientData, int mask) {
    rdma_connection * rdma_conn = (rdma_connection * ) clientData;
    connection * conn = & rdma_conn - > c;
    struct rdma_cm_id * cm_id = rdma_conn - > cm_id;
    RdmaContext * ctx = cm_id - > context;

    int ret = 0;

    UNUSED(el);
    UNUSED(fd);
    UNUSED(mask);

    ret = connRdmaHandleCq(rdma_conn);
    if (ret == C_ERR) {
        conn - > state = CONN_STATE_ERROR;
        return;
    }

    /* uplayer should read all */
    while (!(rdma_conn - > flags & RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE) && ctx - > rx.pos < ctx - > rx.offset) {
        if (conn - > read_handler && (callHandler(conn, conn - > read_handler) == C_ERR)) {
            return;
        }
    }

    /* recv buf is full, register a new RX buffer */
    if (ctx - > rx.pos == ctx - > rx.length) {
        connRdmaRegisterRx(ctx, cm_id);
    }

    /* RDMA comp channel has no POLLOUT event, try to send remaining buffer */
    if (!(rdma_conn - > flags & RDMA_CONN_FLAG_POSTPONE_UPDATE_STATE) && ctx - > tx.offset < ctx - > tx.length && conn - > write_handler) {
        callHandler(conn, conn - > write_handler);
    }
}

During this state update it also callHandlers into the registered read_handler. Who is read_handler? readQueryFromClient — reentrancy: walk the same path again down to:

void parseInputBuffer(client *c) {
	/* The command queue must be emptied before parsing. */
	// 指令队列在解析之前必须为空
	serverAssert(c->cmd_queue.len == 0);
	......
}

Reentered while prior work may not be finished → assertion (my inference). processClientIOWriteDone also calls update and enters the read path, causing another:

/* If io_last_written_data_len is nonzero it must relate to c->buf */
serverAssert(c->io_last_written.data_len == 0 || c->io_last_written.buf == c->buf);

assertion failure — this PR tries to fix both.

IO-thread state
#

The two state machines interleave — analyze alternately. Now I am the IO thread.

After main offloaded the client read to us, walk down from the IO main:

static void * IOThreadMain(void * myid) {
        /* The ID is the thread ID number (from 1 to server.io_threads_num-1). ID 0 is the main thread. */
        long id = (long) myid;
        char thdname[32];
        ......
        while (1) {
            ......
            /* PRIORITY 2: Shared Global Queue (SPMC)
             * Only checked after SPSC is drained. */
			 // 这里就是经典的从环形缓冲区尝试进行出队的操作,单生产者多消费者。
            void * tagged_job = spmcDequeue( & io_shared_inbox);
            // 看需要消费的job类型
            if (tagged_job) {
                void * data;
                int type;
                untagJob(tagged_job, & data, & type);
                switch (type) {
                // 
                    case JOB_REQ_READ_CLIENT:
                        ioThreadReadQueryFromClient((client * ) data);
                        break;
                    case JOB_REQ_WRITE_CLIENT:
                        ioThreadWriteToClient((client * ) data);
                        break;
                    case JOB_REQ_FREE_OBJ:
                        zfree(data);
                        break;
                    case JOB_REQ_ACCEPT:
                        ioThreadAccept((client * ) data);
                        break;
                    case JOB_REQ_POLL:
                        ioThreadPoll((aeEventLoop * ) data);
                        break;
                    default:
                        serverPanic("Invalid SPMC job type: %d", type);
                }
                processed++;
                ......
            } {

When _main enqueued, the job tag was JOB_REQ_READ_CLIENT (code path for enqueue failure):

    if (unlikely(spmcEnqueue( & io_shared_inbox, tagJob(c, JOB_REQ_READ_CLIENT)) == false)) {
        c - > read_flags = 0;
        c - > io_read_state = CLIENT_IDLE;
        connSetPostponeUpdateState(c - > conn, 0);
        return C_ERR;
    }

OK, continue: in ioMain’s switch we call ioThreadReadQueryFromClient:

void ioThreadReadQueryFromClient(client * c) {
	// 断言,刚进这个函数的时候,我们的状态肯定是 `CLIENT_PENDING_IO` 就是等待处理的状态。
    serverAssert(c-> io_read_state == CLIENT_PENDING_IO);

    /* Read */
    readToQueryBuf(c);
    if (c - > flag.close_asap) {
        goto done;
    }

    /* Check for read errors. */
    if (c - > nread <= 0) {
        goto done;
    }

    /* Skip command parsing if the READ_FLAGS_DONT_PARSE flag is set. */
    if (c - > read_flags & READ_FLAGS_DONT_PARSE) {
        goto done;
    }

    /* Handle QB limit */
    if (c - > read_flags & READ_FLAGS_QB_LIMIT_REACHED) {
        goto done;
    }
	
	// ======== 这个解析就是重入点,之前的指令主线程还没处理结束 ========
    parseInputBuffer(c);
    
    trimCommandQueue(c);
    prepareCommandQueue(c);

    /* Parsing was not completed - let the main-thread handle it. */
    if (!(c - > read_flags & READ_FLAGS_PARSING_COMPLETED)) {
        goto done;
    }

    /* Empty command - Multibulk processing could see a <= 0 length. */
    if (c - > argc == 0) {
        goto done;
    }

    done:
        /* Only trim query buffer for non-primary clients
         * Primary client's buffer is handled by main thread using repl_applied position */
        if (!(c - > read_flags & READ_FLAGS_REPLICATED)) {
            trimClientQueryBuffer(c);
        }

    c - > io_read_state = CLIENT_COMPLETED_IO;
    c - > cur_tid = getCurTid();
    sendToMainThread(c, JOB_RES_READ_CLIENT);
}

Here we read into the buffer and parse (study later; not the focus), and after finishing the read client we set the state machine to CLIENT_COMPLETED_IO — IO is done; return the result to main:

c - > io_read_state = CLIENT_COMPLETED_IO;
c - > cur_tid = getCurTid();
sendToMainThread(c, JOB_RES_READ_CLIENT);

What does sendToMainThread do?

void sendToMainThread(void * data, int type) {
    if (unlikely(pending_io_responses)) {
        flushPendingIOResponses(0);
    }

	// 拿到我们IO当前完成的任务
    void * job = tagJob(data, type);
    // 把我们当前完成的job放进一个队列中,这是所有IO都放到一个队列内部,然后主线程进行对应的处理。
    // 所以就是多生产者(IO线程)单消费者(_main线程)的链表
    if (unlikely(pending_io_responses || !mpscEnqueue( & io_shared_outbox, job, & io_thread_ticket))) {
        /* Failed to push new job: initialize list if needed and save job */
        // 实在没有放进去的话我们就开启一个全新的队列(这个处理机制是什么???)
        if (pending_io_responses == NULL) {
            pending_io_responses = listCreate();
        }
        listAddNodeTail(pending_io_responses, job);
    }
}

OK — IO’s job is done: after the read client, enqueue the result, then back to _mainback to main

A more elegant fix?
#

Root cause analyzed; want a cleaner fix — upper layers look dirty.
You should not write `if (conn type == RDMA) { ... }` — ugly. Is there a cleaner approach?

Idea: instead of type checks upstairs, use postpone_update_state more objectively; but many edits, easy to get wrong — needs more study; logic is too complex. Prefer a minimal change series, not one big rewrite. Such fixes are never one-shot OK — inherently complex.

When showing AI or needing an unpaginated git diff:

$ git --no-pager -diff # 这个是你可以展示目前工作区的diff

If already committed, how to view:

$ git --no-pager show <commit_id> # 不分页展示某个已经提交的commit

Both are very useful. Looks about done — from February when IO jobs were not yet MPMC (I fixed the old IO bugs), then the IO model changed and I fixed the new one; somehow it is late May — time flies; also fixed two smaller issues. Hope merge goes smoothly!

Timeline after our change is roughly:

sequenceDiagram
    participant Epoll as RDMA/epoll
    participant Main as 主线程
    participant IO as IO 线程
    participant RDMA as connRdmaEventHandler

    Epoll->>Main: readQueryFromClient
    Main->>IO: trySendReadToIOThreads → inbox
    Note over Main: PENDING_IO + postpone READ(这里推迟了读路径)

    IO->>IO: ioThreadReadQueryFromClient<br/>parseInputBuffer → cmd_queue
    IO->>Main: COMPLETED_IO → outbox

    Main->>Main: handleReadJobs ① processClientIOReadsDone
    Note over Main: IDLE, mask=READ(+WRITE?), connUpdateState
    Note over RDMA: READ 被挡,不进 read_handler

    Main->>Main: ② processClientsCommandsBatch
    Main->>Main: ③ drain + clientConnPostponeMask + connUpdateState
    Note over RDMA: queue 空 → 可排空 RX

Essence of the PR: at specific points in the client/connection lifecycle, set READ/WRITE bits in postpone_mask; check those bits in RDMA’s connRdmaEventHandler, deciding 「whether this moment may sync-call read/write handlers」.

CI failure
#

On this branch we hit a stable CI failure: the single-io_threads case in test-ubuntu-latest-cmake-tls. My bug or the test’s? Try to fix — first reproduce the test:

# 使用 CMake 编译带 TLS 支持的 Valkey (与 CI 环境保持一致) 
cmake -B build -DUSE_TLS=ON 
cmake --build build -j$(nproc)

Looks like postpone-mask issues remain — for TLS, what behaves wrongly?

Basic test flow
#

pkill -9 -f valkey-server; pkill -9 -f runtest # 首先杀掉原来的进程
make BUILD_TLS=yes -j$(nproc) # 带TLS进行编译
./utils/gen-test-certs.sh     # 生成相应的证书
./runtest --single tests/unit/io-threads.tcl --tls # 跑这个报错的单测

To match test-ubuntu-latest-cmake-tls exactly, our flow is:

rm -rf build-release
mkdir -p build-release
cd build-release
cmake -DCMAKE_BUILD_TYPE=Release .. -DBUILD_TLS=yes -DBUILD_UNIT_GTESTS=yes
make -j$(nproc)
cd ..

On TLS accept you must not seal the read path (fix the if) — otherwise the TLS state machine cannot advance. After that, CI all green.

Then more review:

processClientIOWriteDone(c)
  → connUpdateState(c->conn)
    → updateRdmaState()
      → connRdmaEventHandler()
        → read handler
          → readQueryFromClient()
            → ... parse/execute ...
              → beforeNextClient(c)
                → if (c->flag.close_asap) freeClient(c);  // c is freed
  → come back to processClientIOWriteDone,try to access c  // use-after-free

On this write path,

also look up by ID first, then check existence — prevent the same UAF.

Ping viktor again:

@zuiderkwast Gentle ping on this one when you have bandwidth — it's in the Valkey 9.1 board under **Needs Review**.

Quick recap since the last round of feedback:
- RDMA + IO threads re-entrancy fix via directional `CONN_POSTPONE_READ` / `CONN_POSTPONE_WRITE` (approved by @pizhenwei).
- `handleReadJobs` drains `cmd_queue` before resuming transport.
- Latest follow-ups: removed blanket RDMA postpone skips (950c32b), inlined the read-done postpone mask (5586e73), and addressed the write-path UAF with post-`connUpdateState()` re-lookup (f842d16).

CI is green on the latest commit. No rush at all — just bubbling it up in case it got buried. Thanks!

As of writing, still not merged — but the analysis is clear.