diff --git a/.gitignore b/.gitignore
index 3a3efb0a..7e0f9f9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -101,6 +101,7 @@ tmp/
/target-linux/
/target-linux-tokio/
/target-tokio/
+/target-tokio2/
/target-fast/
/target-check-tokio/
/target-check-monoio/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4a6b115..7b6397da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- **A timed-out cross-shard `BLPOP` no longer eats the next push to that
+ key (c10k hardening A2/A3).** Two defects compounded into silent data
+ loss at `--shards > 1`. First, the tokio single-key cleanup condition
+ `is_remote && !matches!(result, Frame::Null) || matches!(result, Frame::Error(_))`
+ parses as `(is_remote && !Null) || Error`, so on a **timeout** — where
+ the result *is* `Frame::Null` — the `BlockCancel` was never sent and the
+ owning shard kept the `WaitEntry` forever (remote entries carry
+ `deadline: None`, so the deadline sweep never reaps them). Second, when
+ a later push found that ghost waiter, every wake path popped the element
+ and then did `let _ = reply_tx.send(...)`: the receiver was long gone, so
+ the value was neither delivered, nor requeued, nor offered to the next
+ FIFO waiter — it simply vanished. Wakes now skip waiters whose client has
+ disconnected *before* touching the datastore, and restore anything popped
+ if the receiver drops in the remaining race window (including BLMOVE's
+ destination push and BLMPOP's multi-element pops, in order). The same
+ boolean bug also sent a local waiter's shutdown cleanup through
+ `target_index(shard, shard)`, which underflows to a panic on shard 0.
+- **Blocking registrations are no longer silently dropped or retried
+ forever (c10k hardening A4/A5/A6).** Cross-shard `BlockRegister` /
+ `BlockCancel` messages were pushed with a bare `try_push` (tokio) or an
+ uncapped `loop { try_push; sleep(10µs) }` with no shutdown arm (monoio).
+ A dropped `BlockRegister` drops the reply sender with it, so `BLPOP key 0`
+ returned nil at t=0 — a protocol violation; a dropped `BlockCancel` leaked
+ the owner-side waiter permanently; and the uncapped retry turned a
+ saturated owner shard into a zombie connection that also blocked graceful
+ shutdown. All four call sites now use the plane-wide bounded,
+ shutdown-aware push, notify the owning shard on success, and fail the
+ command loudly (`MOONERR blocking registration failed`) rather than
+ blocking on a key nobody is watching.
+
## [0.8.4] — 2026-07-29
### Added
diff --git a/src/blocking/wakeup.rs b/src/blocking/wakeup.rs
index 6997e4fc..205a9769 100644
--- a/src/blocking/wakeup.rs
+++ b/src/blocking/wakeup.rs
@@ -5,6 +5,85 @@ use crate::command::sorted_set::format_score_bytes;
use crate::framevec;
use crate::protocol::Frame;
use crate::storage::Database;
+
+/// What a wake attempt consumed from the datastore, so it can be put back if
+/// the woken client turns out to be gone.
+///
+/// c10k hardening A2: every wake path used to `pop` first and only then
+/// `let _ = reply_tx.send(...)`. A failed send — the overwhelmingly common
+/// case for a client that RST'd or timed out while its registration was still
+/// live on this shard — silently destroyed the popped element: not delivered,
+/// not requeued, not offered to the next FIFO waiter. Redis guarantees an
+/// element is either delivered or stays in the key.
+///
+/// Two defences, because neither alone is sufficient:
+/// 1. `is_disconnected()` is checked BEFORE touching the datastore, so a
+/// known-dead waiter never causes a mutation at all (the common case);
+/// 2. this undo covers the residual race — the receiver can drop between
+/// that check and the send.
+enum WakeUndo {
+ /// Values popped from the FRONT of the key, in pop order.
+ ListFront(smallvec::SmallVec<[bytes::Bytes; 4]>),
+ /// Values popped from the BACK of the key, in pop order.
+ ListBack(smallvec::SmallVec<[bytes::Bytes; 4]>),
+ /// BLMOVE: one value left the key via `wherefrom` and was pushed onto
+ /// `destination` via `whereto`. Undoing means reversing BOTH halves.
+ Moved {
+ destination: Bytes,
+ wherefrom: Direction,
+ whereto: Direction,
+ },
+ /// (member, score) pairs popped from the sorted set at the key.
+ Zset(smallvec::SmallVec<[(bytes::Bytes, f64); 4]>),
+}
+
+impl WakeUndo {
+ /// Put everything back exactly where it came from.
+ fn restore(self, db: &mut Database, key: &Bytes) {
+ match self {
+ // Pops came off the front in order [v0, v1, ..]; pushing them
+ // back front-first in REVERSE order restores the original
+ // sequence (push v_n first, v0 last => v0 ends up at the front).
+ WakeUndo::ListFront(vals) => {
+ for v in vals.into_iter().rev() {
+ db.list_push_front(key, v);
+ }
+ }
+ WakeUndo::ListBack(vals) => {
+ for v in vals.into_iter().rev() {
+ db.list_push_back(key, v);
+ }
+ }
+ WakeUndo::Moved {
+ destination,
+ wherefrom,
+ whereto,
+ } => {
+ // Take back the element we just pushed onto the destination.
+ // It is still at the end we pushed it to: this runs on the
+ // shard thread with no await in between, so nothing else can
+ // have touched the list.
+ let moved = match whereto {
+ Direction::Left => db.list_pop_front(&destination),
+ Direction::Right => db.list_pop_back(&destination),
+ };
+ if let Some(v) = moved {
+ match wherefrom {
+ Direction::Left => db.list_push_front(key, v),
+ Direction::Right => db.list_push_back(key, v),
+ }
+ }
+ }
+ // Sorted sets are order-free: reinsertion order is irrelevant.
+ WakeUndo::Zset(pairs) => {
+ for (member, score) in pairs {
+ db.zset_restore(key, member, score);
+ }
+ }
+ }
+ }
+}
+
/// Called after LPUSH/RPUSH successfully adds elements to a list key.
/// Pops the first waiter (FIFO) and executes the appropriate pop operation.
/// Returns true if a blocked client was woken (element was consumed by the waiter).
@@ -22,29 +101,48 @@ pub fn try_wake_list_waiter(
Some(w) => w,
None => return false,
};
- let wait_id = waiter.wait_id;
+ let crate::blocking::WaitEntry {
+ wait_id,
+ cmd,
+ reply_tx,
+ ..
+ } = waiter;
+
+ // A2: never mutate the datastore on behalf of a waiter whose client
+ // is already gone. Reap the registration and move to the next waiter
+ // with the key untouched.
+ if reply_tx.is_disconnected() {
+ registry.remove_wait(wait_id);
+ continue;
+ }
// Execute the pop based on command type
- let result = match &waiter.cmd {
+ let (result, undo) = match &cmd {
BlockedCommand::BLPop => {
// Pop from left, return [key, value]
- let val = db.list_pop_front(key);
- val.map(|v| {
- Frame::Array(framevec![
- Frame::BulkString(key.clone()),
- Frame::BulkString(v),
- ])
- })
+ match db.list_pop_front(key) {
+ Some(v) => (
+ Some(Frame::Array(framevec![
+ Frame::BulkString(key.clone()),
+ Frame::BulkString(v.clone()),
+ ])),
+ Some(WakeUndo::ListFront(smallvec::smallvec![v])),
+ ),
+ None => (None, None),
+ }
}
BlockedCommand::BRPop => {
// Pop from right, return [key, value]
- let val = db.list_pop_back(key);
- val.map(|v| {
- Frame::Array(framevec![
- Frame::BulkString(key.clone()),
- Frame::BulkString(v),
- ])
- })
+ match db.list_pop_back(key) {
+ Some(v) => (
+ Some(Frame::Array(framevec![
+ Frame::BulkString(key.clone()),
+ Frame::BulkString(v.clone()),
+ ])),
+ Some(WakeUndo::ListBack(smallvec::smallvec![v])),
+ ),
+ None => (None, None),
+ }
}
BlockedCommand::BLMove {
destination,
@@ -55,17 +153,27 @@ pub fn try_wake_list_waiter(
Direction::Left => db.list_pop_front(key),
Direction::Right => db.list_pop_back(key),
};
- val.map(|v| {
- // Push to destination
- match whereto {
- Direction::Left => db.list_push_front(destination, v.clone()),
- Direction::Right => db.list_push_back(destination, v.clone()),
+ match val {
+ Some(v) => {
+ // Push to destination
+ match whereto {
+ Direction::Left => db.list_push_front(destination, v.clone()),
+ Direction::Right => db.list_push_back(destination, v.clone()),
+ }
+ (
+ Some(Frame::BulkString(v)),
+ Some(WakeUndo::Moved {
+ destination: destination.clone(),
+ wherefrom: *wherefrom,
+ whereto: *whereto,
+ }),
+ )
}
- Frame::BulkString(v)
- })
+ None => (None, None),
+ }
}
BlockedCommand::BLMPop { dir, count } => {
- let mut elems = smallvec::SmallVec::<[Frame; 16]>::new();
+ let mut popped = smallvec::SmallVec::<[Bytes; 4]>::new();
let n = *count as usize;
for _ in 0..n {
let val = match dir {
@@ -73,33 +181,49 @@ pub fn try_wake_list_waiter(
Direction::Right => db.list_pop_back(key),
};
match val {
- Some(v) => elems.push(Frame::BulkString(v)),
+ Some(v) => popped.push(v),
None => break,
}
}
- if elems.is_empty() {
- None
+ if popped.is_empty() {
+ (None, None)
} else {
- let elem_vec: Vec = elems.into_vec();
- Some(Frame::Array(framevec![
- Frame::BulkString(key.clone()),
- Frame::Array(elem_vec.into()),
- ]))
+ let elem_vec: Vec =
+ popped.iter().cloned().map(Frame::BulkString).collect();
+ let undo = match dir {
+ Direction::Left => WakeUndo::ListFront(popped),
+ Direction::Right => WakeUndo::ListBack(popped),
+ };
+ (
+ Some(Frame::Array(framevec![
+ Frame::BulkString(key.clone()),
+ Frame::Array(elem_vec.into()),
+ ])),
+ Some(undo),
+ )
}
}
- _ => None, // BZPopMin/BZPopMax don't watch list keys
+ _ => (None, None), // BZPopMin/BZPopMax don't watch list keys
};
// Clean up all other key registrations for this wait_id
registry.remove_wait(wait_id);
if let Some(frame) = result {
- let _ = waiter.reply_tx.send(Some(frame));
- return true;
+ if reply_tx.send(Some(frame)).is_ok() {
+ return true;
+ }
+ // A2 residual race: the receiver dropped between the liveness
+ // check above and this send. Put the data back and offer it to
+ // the next waiter instead of destroying it.
+ if let Some(undo) = undo {
+ undo.restore(db, key);
+ }
+ continue;
}
// If pop returned None (list became empty -- shouldn't happen in single-threaded
// model but handle gracefully), try next waiter
- let _ = waiter.reply_tx.send(None);
+ let _ = reply_tx.send(None);
}
false
}
@@ -118,62 +242,93 @@ pub fn try_wake_zset_waiter(
Some(w) => w,
None => return false,
};
- let wait_id = waiter.wait_id;
-
- let result = match &waiter.cmd {
- BlockedCommand::BZPopMin => db.zset_pop_min(key).map(|(member, score)| {
- Frame::Array(framevec![
- Frame::BulkString(key.clone()),
- Frame::BulkString(member),
- Frame::BulkString(format_score_bytes(score)),
- ])
- }),
- BlockedCommand::BZPopMax => db.zset_pop_max(key).map(|(member, score)| {
- Frame::Array(framevec![
- Frame::BulkString(key.clone()),
- Frame::BulkString(member),
- Frame::BulkString(format_score_bytes(score)),
- ])
- }),
+ let crate::blocking::WaitEntry {
+ wait_id,
+ cmd,
+ reply_tx,
+ ..
+ } = waiter;
+
+ // A2: see try_wake_list_waiter — never pop for a dead client.
+ if reply_tx.is_disconnected() {
+ registry.remove_wait(wait_id);
+ continue;
+ }
+
+ let (result, undo) = match &cmd {
+ BlockedCommand::BZPopMin => match db.zset_pop_min(key) {
+ Some((member, score)) => (
+ Some(Frame::Array(framevec![
+ Frame::BulkString(key.clone()),
+ Frame::BulkString(member.clone()),
+ Frame::BulkString(format_score_bytes(score)),
+ ])),
+ Some(WakeUndo::Zset(smallvec::smallvec![(member, score)])),
+ ),
+ None => (None, None),
+ },
+ BlockedCommand::BZPopMax => match db.zset_pop_max(key) {
+ Some((member, score)) => (
+ Some(Frame::Array(framevec![
+ Frame::BulkString(key.clone()),
+ Frame::BulkString(member.clone()),
+ Frame::BulkString(format_score_bytes(score)),
+ ])),
+ Some(WakeUndo::Zset(smallvec::smallvec![(member, score)])),
+ ),
+ None => (None, None),
+ },
BlockedCommand::BZMPop { min, count } => {
let n = *count as usize;
- let mut elems = smallvec::SmallVec::<[Frame; 16]>::new();
+ let mut popped = smallvec::SmallVec::<[(Bytes, f64); 4]>::new();
for _ in 0..n {
- let popped = if *min {
+ let entry = if *min {
db.zset_pop_min(key)
} else {
db.zset_pop_max(key)
};
- match popped {
- Some((member, score)) => {
- elems.push(Frame::Array(framevec![
- Frame::BulkString(member),
- Frame::BulkString(format_score_bytes(score)),
- ]));
- }
+ match entry {
+ Some(pair) => popped.push(pair),
None => break,
}
}
- if elems.is_empty() {
- None
+ if popped.is_empty() {
+ (None, None)
} else {
- let elem_vec: Vec = elems.into_vec();
- Some(Frame::Array(framevec![
- Frame::BulkString(key.clone()),
- Frame::Array(elem_vec.into()),
- ]))
+ let elem_vec: Vec = popped
+ .iter()
+ .map(|(member, score)| {
+ Frame::Array(framevec![
+ Frame::BulkString(member.clone()),
+ Frame::BulkString(format_score_bytes(*score)),
+ ])
+ })
+ .collect();
+ (
+ Some(Frame::Array(framevec![
+ Frame::BulkString(key.clone()),
+ Frame::Array(elem_vec.into()),
+ ])),
+ Some(WakeUndo::Zset(popped)),
+ )
}
}
- _ => None, // List commands don't watch zset keys
+ _ => (None, None), // List commands don't watch zset keys
};
registry.remove_wait(wait_id);
if let Some(frame) = result {
- let _ = waiter.reply_tx.send(Some(frame));
- return true;
+ if reply_tx.send(Some(frame)).is_ok() {
+ return true;
+ }
+ // A2 residual race — restore and offer to the next waiter.
+ if let Some(undo) = undo {
+ undo.restore(db, key);
+ }
+ continue;
}
- let _ = waiter.reply_tx.send(None);
+ let _ = reply_tx.send(None);
}
false
}
@@ -197,9 +352,26 @@ pub fn try_wake_stream_waiter(
Some(w) => w,
None => return false,
};
- let wait_id = waiter.wait_id;
+ let crate::blocking::WaitEntry {
+ wait_id,
+ cmd,
+ reply_tx,
+ ..
+ } = waiter;
- let result = match &waiter.cmd {
+ // A2: skip waiters whose client is already gone. Unlike the list and
+ // zset paths there is no undo here, and none is needed: XRead is a
+ // non-destructive range read, and XReadGroup's delivery leaves the
+ // entries in the stream — only the PEL records them as delivered to a
+ // consumer that vanished, which is exactly Redis's dead-consumer
+ // semantics (recoverable via XAUTOCLAIM). The pre-check still matters
+ // so a dead waiter does not consume the wake that a live one needs.
+ if reply_tx.is_disconnected() {
+ registry.remove_wait(wait_id);
+ continue;
+ }
+
+ let result = match &cmd {
BlockedCommand::XRead { streams, count } => {
// Find this key's last_seen_id in the streams list
let last_seen = streams.iter().find(|(k, _)| k == key).map(|(_, id)| *id);
@@ -272,10 +444,172 @@ pub fn try_wake_stream_waiter(
registry.remove_wait(wait_id);
if let Some(frame) = result {
- let _ = waiter.reply_tx.send(Some(frame));
+ let _ = reply_tx.send(Some(frame));
return true;
}
- let _ = waiter.reply_tx.send(None);
+ let _ = reply_tx.send(None);
}
false
}
+
+#[cfg(test)]
+mod dead_waiter_tests {
+ use super::*;
+ use crate::blocking::WaitEntry;
+ use crate::storage::Database;
+
+ fn register(
+ reg: &mut BlockingRegistry,
+ key: &Bytes,
+ cmd: BlockedCommand,
+ ) -> crate::runtime::channel::OneshotReceiver