Skip to content

fix(protocol): name protocol faults on the wire, and make MULTI atomic at queue time - #472

Merged
TinDang97 merged 5 commits into
mainfrom
fix/protocol-error-and-multi-queue-semantics
Aug 13, 2026
Merged

fix(protocol): name protocol faults on the wire, and make MULTI atomic at queue time#472
TinDang97 merged 5 commits into
mainfrom
fix/protocol-error-and-multi-queue-semantics

Conversation

@TinDang97

@TinDang97 TinDang97 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes two v0-9-client-compat tasks: protocol-error-lifetime and multi-exec-queue-semantics.

Everything below was measured against redis-server 8.6.1 over raw sockets — redis-cli cannot express a malformed frame, two commands in one write, or the difference between $-1 and *-1.

1 · A protocol fault now names itself instead of closing mute

The read loops did this:

Err(_) => { break_outer = true; break; }

That arm threw away two things: the parse reason — so a client got a bare FIN and could not tell a bad encoder from a dropped network — and batch, which already held every valid frame parsed from the same read. PING\r\n*-9\r\n in one write therefore answered nothing at all.

All three handlers now execute and flush the valid prefix, then send -ERR Protocol error: <reason>, then close, in that order. ParseError::Invalid gains a typed kind carrying Redis's verbatim wording; the detailed message is kept for logs and fuzz triage, deliberately different (invalid bulk string length: -5 localises a bug; invalid bulk length is what a driver author matches on).

Measured and fixed alongside it:

sent before after
*-9\r\n closes connection ignored, connection alive (Redis parity)
$abc / $-5 / $999999999 closes, no error -ERR Protocol error: invalid bulk length, then closes
200 KB inline closes mute -ERR Protocol error: too big inline request — Moon already built this string; the handler discarded it
GET "unclosed replies $-1 — accepts it as a key -ERR Protocol error: unbalanced quotes in request
PING\r\n*-9\r\n no reply +PONG, then the fault

The inline parser had no quote support whatsoever, so SET k "a b" became three arguments containing literal quote bytes. It is now a port of Redis's sdssplitargs (hex/escape handling, closing-quote-must-end-token, unbalanced rejection), gated behind one memchr2 pass so the unquoted hot path is untouched.

2 · MULTI is atomic with respect to queue-time faults

Moon had no queue-time validation, so EXEC ran whichever half of a transaction happened to parse:

MULTI / NOSUCHCMD / SET k v / EXEC
  redis -> k UNSET   (EXECABORT, nothing ran)
  moon  -> k SET     (the valid half ran)

That is data corruption, not a compatibility nit. multi_dirty (Redis's CLIENT_DIRTY_EXEC) is now set when a command that could never run is queued; EXEC answers -EXECABORT Transaction discarded because of previous errors. and executes nothing. DISCARD and RESET clear it — a leaked flag would abort an innocent later transaction, which is worse than the bug being fixed, so it has its own test (me3).

The wider defect this exposed

Moon decided "am I in a transaction?" hundreds of lines below the INFO / CLIENT / WS / MQ / PUBLISH / SUBSCRIBE intercepts — so every one of those executed for real inside a transaction. SUBSCRIBE ch put the connection into subscriber mode mid-MULTI; INFO server returned a 3 KB dump where Redis returns +QUEUED.

The queue decision now sits in one gate directly below each handler's ACL check, fixing the whole class at once. Below the ACL gate, deliberately: Redis refuses a forbidden command at queue time, and this repo has already shipped an ACL bypass caused by an intercept sitting above its permission check.

Safety of the arity/existence check

It reads the same COMMAND_META table dispatch reads, so a command cannot become queueable-but-undispatchable. A name containing . that misses the table falls through to queueing: the table does not cover every dotted extension family, and rejecting one would break a command that works fine outside a transaction — a regression strictly worse than the bug. Pinned by me10b.

Tests

tests/protocol_error_lifetime.rs (8) · tests/multi_exec_queue_semantics.rs (12). Raw sockets throughout; each protocol case asserts the reply bytes, whether the server hung up, and whether a following command still gets served — any one alone hides the interesting half.

Green on both runtimes: monoio (default/shipped) and runtime-tokio,jemalloc. That matters here — handler_sharded and handler_single are not even compiled under monoio, so a fix in only one handler would have been invisible to whichever job built the other.

Known gap, filed not hidden

me7 is #[ignore]d with its reason. BLPOP-in-MULTI returns a Null Bulk where Redis returns a Null Array, and Frame has no null-array variant at allFrame::Null serialises to $-1 in RESP2, full stop. So this is not a MULTI bug: a plain BLPOP key 1 that times out outside a transaction returns the wrong type too. The fix is a Frame::NullArray threaded through ~14 Frame::Null arms in serialize.rs/resp3.rs — every reply path — and deserves its own contract. The test is already the right assertion, waiting to be un-ignored.

Summary by CodeRabbit

  • Bug Fixes
    • Improved protocol-error responses for malformed RESP and inline requests, with clearer messages and safer connection handling.
    • Valid commands received before malformed data are now processed before the connection closes.
    • Added Redis-compatible quoted inline arguments and validation for oversized or unbalanced input.
    • Improved MULTI behavior: invalid or unsupported commands are rejected when queued, dirty transactions abort on EXEC, and DISCARD/RESET restore normal state.
    • Blocking commands in transactions now receive compatible responses.
    • Connections continue serving valid requests after recoverable command errors.

…c at queue time

Two client-compatibility tasks measured against redis-server 8.6.1 with raw
sockets, since redis-cli cannot express a malformed frame or two commands in
one write.

PROTOCOL ERROR LIFETIME

A malformed frame used to close the connection silently AND discard the valid
frames that preceded it in the same read:

    Err(_) => { break_outer = true; break; }

That arm threw away the parse reason (so a client could not tell a bad encoder
from a dropped network) and `batch`, which already held the parsed prefix — so
`PING\r\n*-9\r\n` in one write answered nothing at all.

  * ParseError::Invalid gains a typed `kind` (ProtoFault) carrying Redis's
    verbatim wire text; the detailed message is kept for logs and fuzz triage.
    The two are deliberately different: "invalid bulk string length: -5"
    localises a bug, "invalid bulk length" is what a driver author matches on.
  * All three handlers now execute and flush the valid prefix, THEN write
    `-ERR Protocol error: <reason>`, THEN close. One shared helper so they
    cannot drift.
  * RespCodec parks the fault in `last_fault` — the Decoder trait pins the
    error type to io::Error, which cannot carry it.
  * `*-9` (any negative multibulk count) is consumed and yields no frame,
    matching measured Redis behaviour; it used to kill the connection.
  * Inline quoting is a port of Redis's sdssplitargs. Moon had NO quote
    support at all, so `SET k "a b"` became three arguments with literal quote
    bytes, and an unterminated quote was silently accepted as part of a key.
    memchr2 gates it, leaving the unquoted hot path untouched.

MULTI QUEUE SEMANTICS

Moon had no queue-time validation, so EXEC ran the half of a transaction that
happened to parse. Measured: `MULTI / NOSUCHCMD / SET k v / EXEC` left k SET
where Redis discards everything. That is data corruption, not a wording nit.

  * `multi_dirty` on ConnectionState (Redis CLIENT_DIRTY_EXEC): a command that
    could never run is refused at queue time and poisons the transaction; EXEC
    answers -EXECABORT and executes nothing. DISCARD and RESET clear the flag —
    a leaked flag would abort an innocent later transaction, which is worse
    than the bug being fixed, so it has its own test.
  * A MULTI QUEUE GATE now sits directly below each handler's ACL gate.
    Redis executes exactly six commands during a transaction; everything else
    queues. Moon decided this hundreds of lines further down, below the INFO /
    CLIENT / WS / MQ / PUBLISH / SUBSCRIBE intercepts, so each of those ran for
    real: `SUBSCRIBE ch` entered subscriber mode mid-MULTI and `INFO server`
    returned a 3 KB dump where Redis returns +QUEUED. The gate is deliberately
    BELOW the ACL check — this repo has already shipped one ACL bypass caused
    by an intercept sitting above its permission check.
  * Queue-time validation reads the same COMMAND_META table dispatch reads, so
    a command cannot become queueable-but-undispatchable. A name containing '.'
    that misses the table falls through to queueing: the table does not cover
    every dotted extension family, and rejecting one would break a command that
    works fine outside a transaction.

TESTS

tests/protocol_error_lifetime.rs — 8 tests, 8 green.
tests/multi_exec_queue_semantics.rs — 12 tests, 11 green.

me7 is #[ignore]d with its reason, not weakened: BLPOP-in-MULTI returns a Null
Bulk where Redis returns a Null Array, and `Frame` has no null-array variant at
all — `Frame::Null` serialises to `$-1` in RESP2, full stop. That affects BLPOP
everywhere, not just in transactions, and needs its own contract.

Refs: .add/tasks/protocol-error-lifetime, .add/tasks/multi-exec-queue-semantics
author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds typed Redis-compatible protocol faults, inline quoting support, valid-prefix processing before protocol-error closure, and queue-time MULTI validation. Dirty transactions now return EXECABORT, while DISCARD and RESET clear dirty state. Raw TCP tests cover both behaviors.

Changes

Protocol and transaction behavior

Layer / File(s) Summary
Typed faults and RESP parsing
src/protocol/frame.rs, src/protocol/inline.rs, src/protocol/parse.rs, src/protocol/mod.rs
Adds ProtoFault classifications, Redis-compatible wire text, quoted inline parsing, negative multibulk handling, and typed parser errors.
Protocol fault delivery
src/server/codec.rs, src/server/conn/util.rs, src/server/conn/handler_*.rs, src/server/conn/handler_monoio/*, src/server/conn/handler_sharded/*
Retains protocol faults, executes valid frame prefixes, sends protocol errors, and closes connections.
MULTI queue validation and cleanup
src/server/conn/core.rs, src/server/conn/shared.rs, src/server/conn/handler_*.rs, src/server/conn/handler_monoio/*, src/server/conn/handler_sharded/*
Adds queue-time command validation and dirty transaction state. Dirty EXEC returns EXECABORT; DISCARD and RESET clear the state.
Raw integration coverage
tests/protocol_error_lifetime.rs, tests/multi_exec_queue_semantics.rs
Adds TCP tests for protocol errors, connection lifetime, transaction poisoning, cleanup, dispatch paths, and shard consistency.
Specifications and release notes
.add/state.json, .add/tasks/*/TASK.md, CHANGELOG.md, scripts/client-compat/manifest.yaml
Updates task state, documents both contracts, and records compatibility assertions and remaining divergence.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RespCodec
  participant ConnectionHandler
  Client->>RespCodec: send valid frames and malformed frame
  RespCodec->>ConnectionHandler: decoded prefix and retained ProtoFault
  ConnectionHandler->>Client: flush valid replies
  ConnectionHandler->>Client: send protocol error
  ConnectionHandler->>Client: close connection
Loading
sequenceDiagram
  participant Client
  participant ConnectionHandler
  participant TransactionState
  Client->>ConnectionHandler: send MULTI
  Client->>ConnectionHandler: send invalid queued command
  ConnectionHandler->>TransactionState: set multi_dirty
  ConnectionHandler->>Client: return queue-time error
  Client->>ConnectionHandler: send EXEC
  ConnectionHandler->>TransactionState: clear queue and dirty state
  ConnectionHandler->>Client: return EXECABORT
Loading

Possibly related PRs

  • pilotspace/moon#230: Both PRs modify inline RESP parsing and inline request size handling.
  • pilotspace/moon#247: Both PRs modify sharded MULTI/EXEC handling and shared transaction logic.
  • pilotspace/moon#250: Both PRs modify MULTI queueing and connection-handler transaction semantics.

Suggested reviewers: pilotspacex-byte

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description thoroughly explains the changes, tests, performance-sensitive parsing, and known gap, but it omits the required Checklist, Performance Impact, and Notes headings. Add the template sections, mark each checklist item, state the performance impact explicitly, and include reviewer notes or state that none apply.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies both primary changes: protocol fault naming and atomic MULTI queue-time behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/protocol-error-and-multi-queue-semantics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/server/conn/handler_monoio/mod.rs (3)

1883-1897: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove the now-unreachable MULTI queue block.

The new gate at line 1490 consumes every frame while conn.in_multi is true, except transaction-control commands. Transaction-control commands are consumed by try_handle_multi_exec at line 1794 and never fall through. Nothing between line 1490 and here sets conn.in_multi and continues to this point, so this block is dead code.

src/server/conn/handler_sharded/mod.rs deleted its equivalent block in this PR. Keeping the monoio copy leaves two runtimes with different queueing code and invites divergence. The stale copy also lacks the multi_dirty and blocking-conversion logic, so a future edit applied here would silently do nothing.

♻️ Proposed removal
-            // --- MULTI queue mode: queue commands when in transaction ---
-            if conn.in_multi {
-                // FT.* vector commands aren't wired through the txn execution
-                // path; reject them explicitly inside MULTI (matches
-                // handler_single) instead of an incidental later error.
-                if cmd.len() > 3 && cmd[..3].eq_ignore_ascii_case(b"FT.") {
-                    responses.push(Frame::Error(Bytes::from_static(
-                        b"ERR FT.* commands are not supported inside MULTI/EXEC",
-                    )));
-                    continue;
-                }
-                conn.command_queue.push(frame);
-                responses.push(Frame::SimpleString(Bytes::from_static(b"QUEUED")));
-                continue;
-            }
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/handler_monoio/mod.rs` around lines 1883 - 1897, Remove the
unreachable MULTI queue-handling block in the monoio connection handler,
including its FT.* rejection, command queuing, QUEUED response, and continue
path. Keep the surrounding transaction gate and align this runtime with the
already-updated sharded handler.

3230-3264: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A connection migration returns before the protocol-fault delivery check, so the fault is discarded. Both sharded handlers place the migration return ahead of the proto_fault.take() block at the end of the batch. proto_fault lives on the handler's stack and is not carried in MigratedConnectionState, so the malformed bytes are consumed, no -ERR Protocol error: ... is sent, and the connection keeps serving on the target shard instead of closing.

  • src/server/conn/handler_monoio/mod.rs#L3230-L3264: add && proto_fault.is_none() to the migration condition, or move the fault check above the migration block.
  • src/server/conn/handler_sharded/mod.rs#L2600-L2619: apply the same guard to the conn.migration_target condition so the fault check at lines 2637-2642 always runs first.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/handler_monoio/mod.rs` around lines 3230 - 3264, Ensure
protocol faults are delivered before connection migration: in
src/server/conn/handler_monoio/mod.rs lines 3230-3264, prevent the migration
branch from running when proto_fault is present; in
src/server/conn/handler_sharded/mod.rs lines 2600-2619, apply the same guard to
the conn.migration_target condition. Preserve the existing proto_fault handling
so the error frame is sent and the connection closes instead of migrating.

797-799: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The subscriber-mode parse loop still closes silently on a protocol fault.

This arm returns MonoioHandlerResult::Done without taking the fault or writing an error. The PR objective states that protocol faults produce a Redis-compatible -ERR Protocol error: <reason> before the close. A client that sends a malformed frame while subscribed still sees a bare disconnect.

🐛 Proposed fix
-                                    Err(_) => return (MonoioHandlerResult::Done, None),  // parse error
+                                    Err(_) => {
+                                        // Same contract as the main parse loop:
+                                        // name the fault before closing.
+                                        if let Some(kind) = codec.take_last_fault() {
+                                            let data = bytes::Bytes::from(
+                                                super::util::proto_error_frame(kind),
+                                            );
+                                            let (_wr, _b): (std::io::Result<usize>, bytes::Bytes) =
+                                                stream.write_all(data).await;
+                                        }
+                                        return (MonoioHandlerResult::Done, None);
+                                    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/handler_monoio/mod.rs` around lines 797 - 799, Update the
subscriber-mode parse loop’s Err arm to capture the parse error, write a
Redis-compatible “-ERR Protocol error: <reason>” response, then close with
MonoioHandlerResult::Done. Preserve the Ok(None) need-more-data behavior and
ensure the error reason comes from the parser instead of being discarded.
🧹 Nitpick comments (2)
tests/multi_exec_queue_semantics.rs (1)

394-403: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test dirty-state cleanup through RESET.

me9 resets a clean transaction. Add MULTI, NOSUCHCMD, RESET, MULTI, SET, and EXEC. Assert that the second EXEC does not return EXECABORT.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/multi_exec_queue_semantics.rs` around lines 394 - 403, Extend the
transaction test around RESET to cover a dirty MULTI state: send MULTI,
NOSUCHCMD, and RESET, then start a new MULTI, queue SET, and execute it. Assert
the second EXEC succeeds without containing EXECABORT, verifying RESET clears
the prior transaction error state.
src/server/conn/handler_monoio/mod.rs (1)

1490-1490: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Align is_transaction_control with its documentation. The predicate exempts seven transaction commands, including UNWATCH, plus five replication commands: REPLCONF, PSYNC, SYNC, REPLICAOF, and SLAVEOF. Add focused tests for these exemptions and for ordinary commands such as SELECT.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/handler_monoio/mod.rs` at line 1490, Update
is_transaction_control and its callers to classify all documented
transaction-control exemptions, including UNWATCH and the replication commands
REPLCONF, PSYNC, SYNC, REPLICAOF, and SLAVEOF, while keeping ordinary commands
such as SELECT non-exempt. Add focused tests covering each exemption and the
ordinary-command behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.add/tasks/multi-exec-queue-semantics/TASK.md:
- Around line 261-281: The test plan must explicitly cover transaction
dirty-state cleanup after poisoned RESET and dirty EXEC, not only poisoned
DISCARD. Extend the relevant multi-exec tests around me3/me9 with cases that
poison a transaction, issue RESET or EXEC as appropriate, then start a fresh
MULTI and verify it queues and executes successfully.
- Around line 261-281: Extend the transaction test plan with explicit wire-level
cases for INFO, CLIENT, SUBSCRIBE, PUBLISH, WS, and MQ, covering each command’s
pre-EXEC behavior, ACL enforcement, and intended result at EXEC. Anchor these
cases to the existing me* scenarios and ensure SUBSCRIBE coverage remains
consistent while adding the omitted intercepted commands; run them on both
runtimes at shards=1 and shards=4.
- Around line 108-110: Resolve the conflict between the MUST requirement for
BLPOP’s null-array response and the stated ignored gap: either add a distinct
null-array representation to Frame and implement RESP2 *-1 handling in the
relevant BLPOP paths, or remove the requirement and explicitly document the
waiver. Keep the task’s requirements, objectives, and scheduled fix consistent
across all repeated references.
- Around line 117-121: Update the unknown-command transaction scenario to use
Redis’s exact no-argument error form: either add arguments to NOSUCHCMD so the
“with args beginning with” suffix is valid, or expect ERR unknown command
'NOSUCHCMD' without that suffix. Apply the same correction to the me1 assertion
and the corresponding repeated scenario section.
- Around line 111-113: Add AOF and replication acceptance tests covering queued
SELECT behavior in the MULTI/EXEC flow. Verify literal queued SELECT commands
are never persisted, single-handler entries record txn_db, and sharded entries
use the database captured before each dispatch.

In @.add/tasks/protocol-error-lifetime/TASK.md:
- Around line 252-260: The protocol error lifetime test plan must add raw-socket
parser cases for rejected multibulk frames, including a non-numeric multibulk
count and an array element with a non-`$` type. Extend the Reject cases
alongside pe2–pe8 to assert each produces the expected error reply and closes
the connection; keep pe8 focused on ProtoFault::wire_text().
- Around line 143-147: Update the valid-prefix scenario to use a malformed frame
that reaches ParseError::Invalid, such as $abc\r\n, after PING\r\n. Preserve the
expected +PONG response and ensure the PING is not swallowed; retain *-9\r\n
only in the negative-count test.
- Around line 197-212: Update the ProtoFault wire-text API so ExpectedDollar(u8)
and UnknownType(u8) produce exact Redis-compatible messages containing the
offending byte; replace the &'static str-only contract with an owned or
otherwise suitable result and update all wire_text callers accordingly, while
preserving the existing static text for non-byte-carrying variants.
- Around line 257-258: Update the pe7 protocol-fault test plan to explicitly
cover handler_single and both runtimes, ensuring handler_monoio is exercised
under runtime-monoio and runtime-tokio coverage includes the intended handler
combinations. Align the cases with the four payloads currently implemented, or
add the two missing payload cases so all planned parity coverage is represented.

In `@src/protocol/frame.rs`:
- Around line 253-271: Correct the doc comment for ProtoFault::wire_text to
state that the two byte-carrying variants return the placeholder "Protocol
error: expected '$', got '?'", rather than claiming the method returns None.
Keep the existing signature and implementation unchanged.
- Around line 300-308: Update the `test_parse_error_invalid_display` test to
provide the required `kind` field when constructing `ParseError::Invalid`, while
preserving its existing `message` and `offset` assertions.

In `@src/protocol/inline.rs`:
- Around line 235-245: Update split_args_quoted’s unquoted-token parsing to
treat single and double quotes as quote-state transitions rather than appending
them, returning an unbalanced-quotes error when the quote is not closed and
merging quoted content into the current argument when it is. Reuse the Redis
whitespace predicate for token boundaries and post-quote checks, ensuring
whitespace such as vertical tab advances the parser instead of leaving it stuck.

In `@src/server/conn/handler_monoio/mod.rs`:
- Around line 1493-1498: Mark the transaction dirty before rejecting unsupported
FT.* commands in all three handlers:
src/server/conn/handler_monoio/mod.rs#L1493-L1498,
src/server/conn/handler_sharded/mod.rs#L878-L883, and
src/server/conn/handler_single.rs#L1755-L1765. Update each FT.* queue-time
rejection, or centralize it through queue_time_rejection, so conn.multi_dirty is
set before the error frame is pushed and EXEC aborts without applying queued
commands.

In `@src/server/conn/handler_sharded/mod.rs`:
- Around line 871-873: Align the transaction queueing logic around
is_transaction_control with the comment’s claim by exempting the replication
handshake commands handled by the REPLCONF and PSYNC intercepts from queuing
inside an open transaction. Ensure those commands continue to reach their
intercept handlers while other non-transaction-control commands remain queued.

In `@src/server/conn/shared.rs`:
- Around line 1105-1112: Update the arity calculation in the command validation
block to use a non-narrowing integer type capable of representing the parser’s
maximum argument count, avoiding the i16 cast and overflow at large inputs. Keep
the existing exact-arity and minimum-arity comparisons with meta.arity correct
by using compatible types or safe conversion.
- Around line 1004-1019: Remove UNWATCH from the CONTROL list in
is_transaction_control, and update handler_single.rs so its pre-queue handling
no longer consumes UNWATCH; when queued inside MULTI, return OK while preserving
watch-version validation before EXEC. Add runtime-specific two-connection
coverage in tests/multi_exec_queue_semantics.rs around lines 263-270 asserting
that a conflicting write causes EXEC to return a null array.

---

Outside diff comments:
In `@src/server/conn/handler_monoio/mod.rs`:
- Around line 1883-1897: Remove the unreachable MULTI queue-handling block in
the monoio connection handler, including its FT.* rejection, command queuing,
QUEUED response, and continue path. Keep the surrounding transaction gate and
align this runtime with the already-updated sharded handler.
- Around line 3230-3264: Ensure protocol faults are delivered before connection
migration: in src/server/conn/handler_monoio/mod.rs lines 3230-3264, prevent the
migration branch from running when proto_fault is present; in
src/server/conn/handler_sharded/mod.rs lines 2600-2619, apply the same guard to
the conn.migration_target condition. Preserve the existing proto_fault handling
so the error frame is sent and the connection closes instead of migrating.
- Around line 797-799: Update the subscriber-mode parse loop’s Err arm to
capture the parse error, write a Redis-compatible “-ERR Protocol error:
<reason>” response, then close with MonoioHandlerResult::Done. Preserve the
Ok(None) need-more-data behavior and ensure the error reason comes from the
parser instead of being discarded.

---

Nitpick comments:
In `@src/server/conn/handler_monoio/mod.rs`:
- Line 1490: Update is_transaction_control and its callers to classify all
documented transaction-control exemptions, including UNWATCH and the replication
commands REPLCONF, PSYNC, SYNC, REPLICAOF, and SLAVEOF, while keeping ordinary
commands such as SELECT non-exempt. Add focused tests covering each exemption
and the ordinary-command behavior.

In `@tests/multi_exec_queue_semantics.rs`:
- Around line 394-403: Extend the transaction test around RESET to cover a dirty
MULTI state: send MULTI, NOSUCHCMD, and RESET, then start a new MULTI, queue
SET, and execute it. Assert the second EXEC succeeds without containing
EXECABORT, verifying RESET clears the prior transaction error state.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f12460e6-066c-40bc-ab33-ae8982c99e9d

📥 Commits

Reviewing files that changed from the base of the PR and between 28d0824 and e9028ab.

📒 Files selected for processing (19)
  • .add/state.json
  • .add/tasks/multi-exec-queue-semantics/TASK.md
  • .add/tasks/protocol-error-lifetime/TASK.md
  • CHANGELOG.md
  • src/protocol/frame.rs
  • src/protocol/inline.rs
  • src/protocol/mod.rs
  • src/protocol/parse.rs
  • src/server/codec.rs
  • src/server/conn/core.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/handler_monoio/write.rs
  • src/server/conn/handler_sharded/mod.rs
  • src/server/conn/handler_sharded/write.rs
  • src/server/conn/handler_single.rs
  • src/server/conn/shared.rs
  • src/server/conn/util.rs
  • tests/multi_exec_queue_semantics.rs
  • tests/protocol_error_lifetime.rs

Comment thread .add/tasks/multi-exec-queue-semantics/TASK.md
Comment on lines +111 to +113
- Behavior already matching Redis stays matching: nested `MULTI`, `EXEC`/`DISCARD` without
`MULTI`, `DISCARD` inside `MULTI`, `SELECT` inside `MULTI`, empty `MULTI`/`EXEC`, `RESET`
inside `MULTI`, and a `WRONGTYPE` at execution time (a runtime error does NOT abort the block).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Expect persisted-write classification and per-dispatch database capture.
rg -n -C 4 'is_persisted_write|is_write|execute_transaction|selected_db|txn_db|aof' src tests --glob '*.rs'

Repository: pilotspace/moon

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate transaction files ---'
git ls-files 'src/server/conn/*handler*' 'src/server/conn/shared.rs' 'src/metadata*' 'src/command*' | head -200

printf '%s\n' '--- executor definitions and call sites ---'
rg -n -C 8 --glob '*.rs' \
  'fn execute_transaction|execute_transaction_sharded|execute_transaction\(' \
  src/server src/command src/metadata.rs src/metadata 2>/dev/null | head -800

printf '%s\n' '--- persistence classification definitions/usages ---'
rg -n -C 6 --glob '*.rs' \
  'is_persisted_write|pub fn is_write|fn is_write|metadata::is_write|metadata::is_persisted_write' \
  src | head -800

Repository: pilotspace/moon

Length of output: 25396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- shared executor: single-db path ---'
sed -n '145,250p' src/server/conn/shared.rs

printf '%s\n' '--- shared executor: sharded path ---'
sed -n '251,430p' src/server/conn/shared.rs

printf '%s\n' '--- command persistence classification ---'
rg -n -C 10 'is_persisted_write|is_write|SELECT|select' src/command/metadata.rs src/command/transaction.rs | head -500

printf '%s\n' '--- single-handler transaction persistence ---'
sed -n '1425,1495p' src/server/conn/handler_single.rs
rg -n -C 8 'txn_aof_entries|persist_txn_aof|record_local_write_db|aof_entries' src/server/conn/handler_single.rs src/server/conn/handler_sharded/write.rs src/server/conn/handler_monoio/write.rs | head -600

Repository: pilotspace/moon

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- task test/build sections ---'
sed -n '250,315p' .add/tasks/multi-exec-queue-semantics/TASK.md

printf '%s\n' '--- single transaction AOF conversion and replication ---'
sed -n '1485,1575p' src/server/conn/handler_single.rs
rg -n -C 10 'txn_aof_entries|persist_txn_aof|record_local_write_db|entry_db|txn_db' \
  src/server/conn/handler_single.rs src/server/conn/handler_sharded/write.rs \
  src/server/conn/handler_monoio/write.rs src/server/conn/shared.rs | head -700

printf '%s\n' '--- transaction-related tests and task references ---'
rg -n -i -C 3 'multi|exec|select|aof|replicat|database|dirty|reset' \
  tests .add/tasks/multi-exec-queue-semantics/TASK.md \
  --glob '*.rs' --glob '*.md' 2>/dev/null | head -1000

printf '%s\n' '--- read-only structural verifier ---'
python3 - <<'PY'
from pathlib import Path

shared = Path("src/server/conn/shared.rs").read_text()
single = Path("src/server/conn/handler_single.rs").read_text()
task = Path(".add/tasks/multi-exec-queue-semantics/TASK.md").read_text()

checks = {
    "single executor uses persisted-write classification":
        "let is_write = metadata::is_persisted_write(cmd);" in shared,
    "sharded executor uses persisted-write classification":
        "if crate::command::metadata::is_persisted_write(cmd)" in shared,
    "single handler captures transaction db before execution":
        "let txn_db = conn.selected_db;" in single,
    "sharded executor captures db before dispatch":
        "let entry_db = selected;" in shared and
        "with_shard_db(selected" in shared,
    "metadata excludes SELECT from persisted writes":
        "is_write(cmd) && !cmd.eq_ignore_ascii_case(b\"SELECT\")" in
        Path("src/command/metadata.rs").read_text(),
}

for name, ok in checks.items():
    print(f"{'PASS' if ok else 'FAIL'}: {name}")

print("--- task coverage keywords ---")
for term in ("AOF", "replication", "persisted", "selected database", "database context"):
    print(f"{term}: {term.lower() in task.lower()}")
PY

Repository: pilotspace/moon

Length of output: 50372


Add AOF and replication acceptance tests for queued SELECT.

Assert that no literal queued SELECT is persisted. Assert that single-handler entries use txn_db, while sharded entries use the database captured before each dispatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.add/tasks/multi-exec-queue-semantics/TASK.md around lines 111 - 113, Add
AOF and replication acceptance tests covering queued SELECT behavior in the
MULTI/EXEC flow. Verify literal queued SELECT commands are never persisted,
single-handler entries record txn_db, and sharded entries use the database
captured before each dispatch.

Source: Learnings

Comment on lines +117 to +121
- unknown command queued -> "ERR unknown command '<name>', with args beginning with: ..."
- wrong arity queued -> "ERR wrong number of arguments for '<name>' command"
- SUBSCRIBE/UNSUBSCRIBE/PSUBSCRIBE/PUNSUBSCRIBE queued -> "ERR <NAME> is not allowed in transactions"
- WATCH queued -> "ERR WATCH inside MULTI is not allowed"
- EXEC after any of the above -> "EXECABORT Transaction discarded because of previous errors."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the correct no-argument unknown-command reply.

The scenario sends NOSUCHCMD without arguments but expects with args beginning with:. The protocol task states that Redis omits this suffix when no arguments exist. Either send NOSUCHCMD a b in the scenario, or expect ERR unknown command 'NOSUCHCMD'. Make me1 assert the same exact form.

Also applies to: 154-160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.add/tasks/multi-exec-queue-semantics/TASK.md around lines 117 - 121, Update
the unknown-command transaction scenario to use Redis’s exact no-argument error
form: either add arguments to NOSUCHCMD so the “with args beginning with” suffix
is valid, or expect ERR unknown command 'NOSUCHCMD' without that suffix. Apply
the same correction to the me1 assertion and the corresponding repeated scenario
section.

Comment on lines +261 to +281
Coverage target: every Must and every Reject above has one test; suite runs on BOTH runtimes
and at shards=1 AND shards=4 (the §1 ⚠ is about dispatch-path coverage, which shard count changes).

Plan (one test per scenario, asserting wire behavior — never internals):
<test_plan>
- test_<scenario>: arrange <Given> / act <When> / assert <Then> + assert <unchanged>
- me1_unknown_command_aborts_and_applies_nothing: MULTI / NOSUCHCMD / SET k v / EXEC;
assert the unknown-command error, "+QUEUED" for SET, "EXECABORT" for EXEC, and GET k is nil
- me2_wrong_arity_aborts: MULTI / GET / EXEC; assert arity error then EXECABORT
- me3_discard_clears_dirty: poison, DISCARD, then a clean MULTI/SET/EXEC applies
- me4_subscribe_refused_not_executed: assert the error AND that a following PING still answers
"+PONG" (a real SUBSCRIBE would have put the connection in subscriber mode)
- me5_watch_inside_multi_refused: assert the exact Redis text
- me6_bad_frame_inside_multi_names_itself: assert the protocol error arrives BEFORE the close
- me7_blpop_in_multi_is_null_array: assert the RESP2 bytes are "*-1", not "$-1"
- me8_wrongtype_does_not_abort: assert the 2-element array and that the sibling SET applied
- me9_matching_behavior_unchanged: the seven already-correct cases, byte-compared
- me10_every_command_meta_entry_is_queueable: walk COMMAND_META; for each non-exempt entry,
queue it with its minimum valid arity and assert "+QUEUED" — this is the §1 ⚠ mitigation,
the test that catches a command becoming unusable in transactions
- me11_one_command_per_dispatch_path_queues: a write (dispatch), a read (dispatch_read), and
an inline-fast-path command each queue successfully at shards=1 and shards=4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Test dirty-state cleanup for RESET and dirty EXEC.

The contract requires the dirty flag to clear after EXEC, DISCARD, and RESET. me3 covers only poisoned DISCARD. The RESET case in me9 does not prove cleanup after a poisoned transaction. Add poisoned RESET and dirty EXEC cases followed by a fresh MULTI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.add/tasks/multi-exec-queue-semantics/TASK.md around lines 261 - 281, The
test plan must explicitly cover transaction dirty-state cleanup after poisoned
RESET and dirty EXEC, not only poisoned DISCARD. Extend the relevant multi-exec
tests around me3/me9 with cases that poison a transaction, issue RESET or EXEC
as appropriate, then start a fresh MULTI and verify it queues and executes
successfully.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add tests for every intercepted command named by the PR.

The PR objectives and CHANGELOG.md name INFO, CLIENT, SUBSCRIBE, PUBLISH, WS, and MQ. The plan explicitly tests only SUBSCRIBE, WATCH, and generic metadata entries. Add cases that verify each intercepted command does not execute before EXEC, preserves ACL checks, and produces its intended result at EXEC.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.add/tasks/multi-exec-queue-semantics/TASK.md around lines 261 - 281, Extend
the transaction test plan with explicit wire-level cases for INFO, CLIENT,
SUBSCRIBE, PUBLISH, WS, and MQ, covering each command’s pre-EXEC behavior, ACL
enforcement, and intended result at EXEC. Anchor these cases to the existing me*
scenarios and ensure SUBSCRIBE coverage remains consistent while adding the
omitted intercepted commands; run them on both runtimes at shards=1 and
shards=4.

Comment on lines +143 to +147
Scenario: the valid prefix is answered before the fault # Must 2
Given a fresh connection
When it writes "PING\r\n*-9\r\n" in ONE write
Then it receives "+PONG"
And the PING is not swallowed # Moon answers nothing at all today

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a real protocol fault for the valid-prefix scenario.

*-9\r\n is explicitly ignored and never enters ParseError::Invalid. Therefore PING\r\n*-9\r\n cannot verify that a valid frame is flushed before an invalid frame closes the connection. Use PING\r\n$abc\r\n or another malformed frame for this scenario. Keep *-9\r\n only in the negative-count test.

Also applies to: 251-253

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.add/tasks/protocol-error-lifetime/TASK.md around lines 143 - 147, Update
the valid-prefix scenario to use a malformed frame that reaches
ParseError::Invalid, such as $abc\r\n, after PING\r\n. Preserve the expected
+PONG response and ensure the PING is not swallowed; retain *-9\r\n only in the
negative-count test.

Comment thread src/protocol/inline.rs
Comment on lines +235 to +245
None => {
while i < line.len() {
match line[i] {
b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => break,
c => {
current.push(c);
i += 1;
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Redis sdssplitargs inline command parsing behavior for an unquoted token containing a double quote

💡 Result:

In Redis, the sdssplitargs function is used to parse inline command arguments. Its behavior regarding tokens containing double quotes is strict to prevent malformed input [1][2][3]. If a token is unquoted (i.e., not wrapped in double or single quotes), it must not contain double quotes [4][2]. Specifically, sdssplitargs enforces a rule where a closing quote (either double or single) must be immediately followed by a whitespace character or be at the very end of the input string [1][2][3]. If a double quote appears within what is otherwise an unquoted token—such as "foo"bar—the parser will interpret the first double quote as a closing quote and then encounter the subsequent characters (like 'b' or 'a') as a violation of this rule [1][2][3]. This violation triggers an error, and the function returns NULL [1][2][3]. The documentation explicitly cites "foo"bar as an example of an invalid format that causes the parser to fail [1][2][3]. This design ensures that quoted and unquoted segments are not ambiguously mixed, maintaining consistent parsing logic [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- inline.rs relevant sections ---'
sed -n '50,90p;110,245p' src/protocol/inline.rs

printf '%s\n' '--- parser symbols and tests ---'
rg -n -C 3 'split_args_quoted|parse_inline|unbalanced|0x0b|0x0c|sdssplitargs' src/protocol tests 2>/dev/null || true

printf '%s\n' '--- tracked files containing inline parser tests ---'
git ls-files | rg '(^|/)(inline|protocol).*(_test|test|tests)?|inline\.rs$' || true

Repository: pilotspace/moon

Length of output: 27423


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
p = Path("src/protocol/inline.rs").read_text()
for needle in ("fn split_args_quoted", "fn split_args", "parse_frame_zerocopy"):
    pos = p.find(needle)
    print(f"{needle}: {pos}")
PY

printf '%s\n' '--- Redis reference implementation excerpt ---'
if command -v curl >/dev/null 2>&1; then
  curl -L --fail --silent --show-error \
    https://raw.githubusercontent.com/redis/redis/unstable/deps/hiredis/sds.c |
    rg -n -C 18 'sdssplitargs|inq|insq' | head -n 180
else
  printf '%s\n' 'curl unavailable'
fi

Repository: pilotspace/moon

Length of output: 6688


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- parse_inline and token finalization ---'
sed -n '1,115p;230,280p' src/protocol/inline.rs

printf '%s\n' '--- inline parser tests after line 440 ---'
sed -n '280,520p' src/protocol/inline.rs

Repository: pilotspace/moon

Length of output: 12010


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from enum import Enum

def redis_split(line: bytes):
    out, i = [], 0
    whitespace = b" \n\r\t\v\f"
    while True:
        while i < len(line) and line[i] in whitespace:
            i += 1
        if i >= len(line):
            return out
        cur = bytearray()
        inq = insq = False
        while True:
            c = line[i] if i < len(line) else 0
            if inq:
                if c == 92 and i + 1 < len(line) and line[i+1] == ord("x") and i + 3 < len(line):
                    # Representative inputs below do not use hex escapes.
                    cur.extend(line[i+1:i+2]); i += 2
                elif c == 92 and i + 1 < len(line):
                    cur.append(line[i+1]); i += 2
                elif c == ord('"'):
                    if i + 1 < len(line) and line[i+1] not in whitespace:
                        return None
                    inq = False; i += 1; break
                elif i >= len(line):
                    return None
                else:
                    cur.append(c); i += 1
            elif insq:
                if c == 92 and i + 1 < len(line) and line[i+1] == ord("'"):
                    cur.append(ord("'")); i += 2
                elif c == ord("'"):
                    if i + 1 < len(line) and line[i+1] not in whitespace:
                        return None
                    insq = False; i += 1; break
                elif i >= len(line):
                    return None
                else:
                    cur.append(c); i += 1
            else:
                if i >= len(line) or line[i] in whitespace:
                    break
                if c == ord('"'):
                    inq = True; i += 1
                elif c == ord("'"):
                    insq = True; i += 1
                else:
                    cur.append(c); i += 1
        out.append(bytes(cur))

def moon_split(line: bytes):
    # This models split_args_quoted's relevant branches, including its
    # space/tab-only leading and closing checks.
    out, i = [], 0
    while True:
        while i < len(line) and line[i] in b" \t":
            i += 1
        if i >= len(line):
            return out
        cur = bytearray()
        quote = line[i] if line[i] in (ord('"'), ord("'")) else None
        if quote is not None:
            i += 1
        if quote == ord('"') or quote == ord("'"):
            while True:
                if i >= len(line): return None
                c = line[i]
                if c == quote:
                    if i + 1 < len(line) and line[i+1] not in b" \t":
                        return None
                    i += 1; break
                cur.append(c); i += 1
        else:
            while i < len(line) and line[i] not in b" \t\n\r\v\f":
                cur.append(line[i]); i += 1
        out.append(bytes(cur))

cases = [b'SET k v"x', b'SET k v"x foo"', b'SET k "v"x', b'SET k "v" x',
         b'SET\vkey value', b'SET key\v"value"', b'SET key "value"\vnext']
for line in cases:
    print(line, "redis=", redis_split(line), "moon=", moon_split(line))
PY

Repository: pilotspace/moon

Length of output: 145


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
WS_REDIS = set(b" \n\r\t\v\f")
WS_MOON_LEADING = set(b" \t")
WS_MOON_UNQUOTED = set(b" \t\n\r\v\f")

def redis_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_REDIS:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        inq = insq = False
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            c = line[i] if i < len(line) else 0
            if inq:
                if c == ord('"'):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    inq = False; i += 1; break
                cur.append(c); i += 1
            elif insq:
                if c == ord("'"):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    insq = False; i += 1; break
                cur.append(c); i += 1
            else:
                if i >= len(line) or c in WS_REDIS:
                    break
                if c == ord('"'):
                    inq = True; i += 1
                elif c == ord("'"):
                    insq = True; i += 1
                else:
                    cur.append(c); i += 1
        if inq or insq:
            return ("error", "unbalanced quote")
        out.append(bytes(cur))

def moon_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_MOON_LEADING:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        quote = line[i] if line[i] in (ord('"'), ord("'")) else None
        if quote is not None:
            i += 1
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            if quote is not None:
                if i >= len(line):
                    return ("error", "unbalanced quote")
                c = line[i]
                if c == quote:
                    if i + 1 < len(line) and line[i + 1] not in WS_MOON_LEADING:
                        return ("error", "closing quote")
                    i += 1; break
                cur.append(c); i += 1
            else:
                if i >= len(line) or line[i] in WS_MOON_UNQUOTED:
                    break
                cur.append(line[i]); i += 1
        out.append(bytes(cur))

cases = [
    b'SET k v"x',
    b'SET k v"x foo"',
    b'SET k "v"x',
    b'SET k "v" x',
    b'SET key\v"value"',
    b'SET key\n"value"',
    b'SET key "value"\vnext',
]
for case in cases:
    print(repr(case))
    print("  redis:", redis_split(case))
    print("  moon :", moon_split(case))
PY

Repository: pilotspace/moon

Length of output: 820


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
WS_REDIS = set(b" \n\r\t\v\f")
WS_MOON_LEADING = set(b" \t")
WS_MOON_UNQUOTED = set(b" \t\n\r\v\f")

def redis_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_REDIS:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        inq = insq = False
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            if i >= len(line):
                if inq or insq:
                    return ("error", "unbalanced quote")
                break
            c = line[i]
            if inq:
                if c == ord('"'):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    inq = False; i += 1; break
                cur.append(c); i += 1
            elif insq:
                if c == ord("'"):
                    if i + 1 < len(line) and line[i + 1] not in WS_REDIS:
                        return ("error", "closing quote")
                    insq = False; i += 1; break
                cur.append(c); i += 1
            else:
                if c in WS_REDIS:
                    break
                if c == ord('"'):
                    inq = True; i += 1
                elif c == ord("'"):
                    insq = True; i += 1
                else:
                    cur.append(c); i += 1
        out.append(bytes(cur))

def moon_split(line, limit=1000):
    out, i, steps = [], 0, 0
    while True:
        while i < len(line) and line[i] in WS_MOON_LEADING:
            i += 1
        if i >= len(line):
            return ("ok", out)
        cur = bytearray()
        quote = line[i] if line[i] in (ord('"'), ord("'")) else None
        if quote is not None:
            i += 1
        while True:
            steps += 1
            if steps > limit:
                return ("stuck", i)
            if quote is not None:
                if i >= len(line):
                    return ("error", "unbalanced quote")
                c = line[i]
                if c == quote:
                    if i + 1 < len(line) and line[i + 1] not in WS_MOON_LEADING:
                        return ("error", "closing quote")
                    i += 1; break
                cur.append(c); i += 1
            else:
                if i >= len(line) or line[i] in WS_MOON_UNQUOTED:
                    break
                cur.append(line[i]); i += 1
        out.append(bytes(cur))

for case in (
    b'SET k v"x',
    b'SET k v"x foo"',
    b'SET k "v"x',
    b'SET k "v" x',
    b'SET key\v"value"',
    b'SET key\n"value"',
    b'SET key "value"\vnext',
):
    print(repr(case))
    print("  redis:", redis_split(case))
    print("  moon :", moon_split(case))
PY

Repository: pilotspace/moon

Length of output: 834


Match Redis quote and whitespace handling in split_args_quoted

Treat " and ' as quote-state transitions inside unquoted tokens. Do not append them literally. SET k v"x must return an unbalanced-quotes error, while SET k v"x foo" must produce vx foo as one argument. Use the same Redis whitespace predicate for token boundaries and post-quote checks. The current mismatch can leave inputs such as SET key\v"value" stuck at \v because the parser does not advance past it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/protocol/inline.rs` around lines 235 - 245, Update split_args_quoted’s
unquoted-token parsing to treat single and double quotes as quote-state
transitions rather than appending them, returning an unbalanced-quotes error
when the quote is not closed and merging quoted content into the current
argument when it is. Reuse the Redis whitespace predicate for token boundaries
and post-quote checks, ensuring whitespace such as vertical tab advances the
parser instead of leaving it stuck.

Comment on lines +1493 to +1498
if cmd.len() > 3 && cmd[..3].eq_ignore_ascii_case(b"FT.") {
responses.push(Frame::Error(Bytes::from_static(
b"ERR FT.* commands are not supported inside MULTI/EXEC",
)));
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The FT.* rejection inside MULTI does not mark the transaction dirty in any of the three handlers. Each handler rejects FT.* at queue time with an error but leaves conn.multi_dirty false, while the adjacent queue_time_rejection path sets it. MULTI, SET a 1, FT.SEARCH idx q, EXEC therefore errors on the FT.SEARCH and then still applies the SET, where a queue-time error must produce -EXECABORT and run nothing.

  • src/server/conn/handler_monoio/mod.rs#L1493-L1498: set conn.multi_dirty = true; before pushing the FT.* error frame.
  • src/server/conn/handler_sharded/mod.rs#L878-L883: set conn.multi_dirty = true; before pushing the FT.* error frame.
  • src/server/conn/handler_single.rs#L1755-L1765: set conn.multi_dirty = true; before pushing the FT.* error frame; consider folding this check into queue_time_rejection so the three copies cannot drift again.
📍 Affects 3 files
  • src/server/conn/handler_monoio/mod.rs#L1493-L1498 (this comment)
  • src/server/conn/handler_sharded/mod.rs#L878-L883
  • src/server/conn/handler_single.rs#L1755-L1765
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/handler_monoio/mod.rs` around lines 1493 - 1498, Mark the
transaction dirty before rejecting unsupported FT.* commands in all three
handlers: src/server/conn/handler_monoio/mod.rs#L1493-L1498,
src/server/conn/handler_sharded/mod.rs#L878-L883, and
src/server/conn/handler_single.rs#L1755-L1765. Update each FT.* queue-time
rejection, or centralize it through queue_time_rejection, so conn.multi_dirty is
set before the error frame is pushed and EXEC aborts without applying queued
commands.

Comment thread src/server/conn/handler_sharded/mod.rs
Comment thread src/server/conn/shared.rs
Comment on lines +1004 to +1019
pub(crate) fn is_transaction_control(cmd: &[u8]) -> bool {
const CONTROL: [&[u8]; 12] = [
b"MULTI",
b"EXEC",
b"DISCARD",
b"WATCH",
b"UNWATCH",
b"RESET",
b"QUIT",
b"REPLCONF",
b"PSYNC",
b"SYNC",
b"REPLICAOF",
b"SLAVEOF",
];
CONTROL.iter().any(|c| cmd.eq_ignore_ascii_case(c))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Redis 8.6, does UNWATCHqueue afterMULTI? Inspect src/commands/unwatch.json command flags and Redis transaction dispatch behavior.

💡 Result:

In Redis 8.6, the UNWATCH command is queued when sent within a MULTI/EXEC transaction block, rather than being executed immediately [1][2]. While the command is technically accepted by the server inside a transaction, it does not perform its intended function of clearing watched keys at the time of issuance [1]. Because it is queued, it is only processed when EXEC is called, at which point the transaction's status (dirty or clean) has already been determined [1]. Consequently, calling UNWATCH inside a transaction block is functionally ineffective and generally considered a misuse of the command [1][2]. Regarding its implementation, UNWATCH is not explicitly excluded from the queueing mechanism used for other transactional commands [2]. The server's transaction dispatch logic queues commands that are not control commands (like EXEC, DISCARD, or MULTI itself) [2]. Because UNWATCH lacks the specific guard logic that prevents WATCH from being called inside a MULTI block, it follows the default behavior of being queued [1][2]. As of the current Redis version, the development team has opted not to change this behavior to avoid potential breaking changes for client applications that may have inadvertently relied on this existing, albeit ineffective, pattern [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- transaction-control definition ---'
sed -n '970,1040p' src/server/conn/shared.rs

printf '%s\n' '--- queueing and transaction dispatch references ---'
rg -n -C 5 'is_transaction_control|WATCH inside MULTI|UNWATCH|watched|EXEC' src/server/conn/shared.rs tests/multi_exec_queue_semantics.rs

printf '%s\n' '--- relevant test section ---'
sed -n '220,310p' tests/multi_exec_queue_semantics.rs

Repository: pilotspace/moon

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all control-classification call sites ---'
rg -n -C 12 'is_transaction_control\(' src/server/conn

printf '%s\n' '--- UNWATCH dispatch and state mutation ---'
rg -n -C 15 'UNWATCH|unwatch|watched_keys\.clear|watch.*clear|clear.*watch' src/server/conn src/command tests --glob '*.rs'

printf '%s\n' '--- queueing branches around the control check ---'
rg -n -C 30 'is_transaction_control|in_multi|command_queue\.push|queue_time_rejection' src/server/conn/handler_single.rs src/server/conn/handler_sharded src/server/conn/handler_monoio src/server/conn/shared.rs --glob '*.rs'

Repository: pilotspace/moon

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- classification call-site list ---'
rg -n 'is_transaction_control\(' src/server/conn

printf '%s\n' '--- UNWATCH implementation lines ---'
rg -n -C 4 'cmd\.eq_ignore_ascii_case\(b"UNWATCH"\)|watched_keys\.clear\(\)' src/server/conn --glob '*.rs'

printf '%s\n' '--- transaction entry points ---'
rg -n 'try_handle_multi_exec|queue_time_rejection|command_queue\.push' src/server/conn/handler_single.rs src/server/conn/handler_sharded src/server/conn/handler_monoio --glob '*.rs'

Repository: pilotspace/moon

Length of output: 10251


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- sharded transaction routing and queueing ---'
sed -n '820,915p' src/server/conn/handler_sharded/mod.rs

printf '%s\n' '--- monoio transaction routing and queueing ---'
sed -n '1450,1530p' src/server/conn/handler_monoio/mod.rs

printf '%s\n' '--- shared WATCH/UNWATCH handler ---'
sed -n '1,95p' src/server/conn/watch.rs

printf '%s\n' '--- sharded and monoio write-handler dispatch order ---'
sed -n '532,620p' src/server/conn/handler_sharded/write.rs
sed -n '707,790p' src/server/conn/handler_monoio/write.rs

Repository: pilotspace/moon

Length of output: 23313


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path

files = {
    "single": Path("src/server/conn/handler_single.rs"),
    "sharded_mod": Path("src/server/conn/handler_sharded/mod.rs"),
    "monoio_mod": Path("src/server/conn/handler_monoio/mod.rs"),
    "shared": Path("src/server/conn/shared.rs"),
}

for name, path in files.items():
    text = path.read_text()
    lines = text.splitlines()
    print(f"--- {name}: relevant ordering ---")
    needles = (
        'is_transaction_control(cmd)',
        'cmd.eq_ignore_ascii_case(b"UNWATCH")',
        'try_handle_multi_exec(',
        'try_handle_watch_unwatch(',
        'if conn.in_multi {',
        'if conn.in_multi && !is_transaction_control(cmd)',
    )
    for i, line in enumerate(lines, 1):
        if any(needle in line for needle in needles):
            print(f"{i}: {line.strip()}")

print("--- queued transaction executor references to UNWATCH ---")
for path in Path("src/server/conn").rglob("*.rs"):
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if "UNWATCH" in line.upper() or "unwatch" in line:
            print(f"{path}:{i}: {line.strip()}")
PY

printf '%s\n' '--- single-handler dispatch context ---'
sed -n '1620,1660p' src/server/conn/handler_single.rs
sed -n '1748,1782p' src/server/conn/handler_single.rs

printf '%s\n' '--- transaction executor command dispatch ---'
sed -n '145,235p' src/server/conn/shared.rs

Repository: pilotspace/moon

Length of output: 9845


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- single-handler dispatch implementation ---'
rg -n -C 8 'fn dispatch\(|pub.*dispatch\(' src/server/conn src/command --glob '*.rs'

printf '%s\n' '--- UNWATCH command registration and generated handlers ---'
rg -n -i -C 5 'unwatch' src/command src/server/conn --glob '*.rs' --glob '*.json'

printf '%s\n' '--- all command-name dispatch tables near matching implementations ---'
rg -n -i -C 3 'watch|unwatch' src/command --glob '*.rs' --glob '*.json'

Repository: pilotspace/moon

Length of output: 20462


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- command dispatch and unknown-command fallback ---'
sed -n '60,155p' src/command/mod.rs

printf '%s\n' '--- every executable UNWATCH implementation ---'
rg -n -i 'unwatch' src/command src/server/conn --glob '*.rs' --glob '*.json'

Repository: pilotspace/moon

Length of output: 4585


Queue UNWATCH inside MULTI across all runtimes.

When UNWATCH is sent inside MULTI, queue it so EXEC checks watched-key versions before any transaction command can clear them. Remove it from CONTROL, update handler_single.rs so its pre-queue arm does not consume it, and return OK for the queued command because the transaction executor has no UNWATCH dispatch. Add runtime-specific two-connection tests that assert a conflicting write makes EXEC return a null array.

📍 Affects 2 files
  • src/server/conn/shared.rs#L1004-L1019 (this comment)
  • tests/multi_exec_queue_semantics.rs#L263-L270
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/shared.rs` around lines 1004 - 1019, Remove UNWATCH from the
CONTROL list in is_transaction_control, and update handler_single.rs so its
pre-queue handling no longer consumes UNWATCH; when queued inside MULTI, return
OK while preserving watch-version validation before EXEC. Add runtime-specific
two-connection coverage in tests/multi_exec_queue_semantics.rs around lines
263-270 asserting that a conflicting write causes EXEC to return a null array.

Comment thread src/server/conn/shared.rs
Comment on lines +1105 to +1112
// `arity` counts the command name itself, so compare against args + 1.
// Positive = exact; negative = minimum (variadic).
let given = args.len() as i16 + 1;
let bad_arity = if meta.arity >= 0 {
given != meta.arity
} else {
given < -meta.arity
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/server/conn/shared.rs --match queue_time_rejection --view expanded
sed -n '1098,1120p' src/server/conn/shared.rs
rg -n -C 3 --glob '*.rs' 'ParseConfig|MbulkCountTooBig|max.*(mbulk|multibulk|array)' src/protocol src/server

Repository: pilotspace/moon

Length of output: 23315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- arity definition and queue-time call sites ---'
rg -n -C 5 --glob '*.rs' 'arity:|queue_time_rejection\(|CommandMeta|meta\.arity' src

printf '%s\n' '--- parser array-length and frame construction ---'
sed -n '90,115p' src/protocol/parse.rs
sed -n '455,485p' src/protocol/parse.rs
rg -n -C 4 --glob '*.rs' 'FrameVec|Vec<Frame>|args:.*Frame' src/server src/protocol

printf '%s\n' '--- deterministic i16 behavior ---'
python3 - <<'PY'
I16_MIN, I16_MAX = -32768, 32767
for n in (32766, 32767, 32768, 65534, 65535, 65536, 1_048_575):
    cast = ((n + 2**15) % 2**16) - 2**15
    wrapped = ((cast + 1 + 2**15) % 2**16) - 2**15
    print(f"args={n}: cast={cast}, given_release={wrapped}")
PY

Repository: pilotspace/moon

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- queue-time implementation and dispatch arity checks ---'
sed -n '1038,1120p' src/server/conn/shared.rs
rg -n -C 8 --glob '*.rs' 'wrong number of arguments|arity_ok|meta\.arity|argv\.len|args\.len' \
  src/server/conn src/command | head -n 240

printf '%s\n' '--- parser array limits and frame construction ---'
sed -n '88,112p' src/protocol/parse.rs
sed -n '450,486p' src/protocol/parse.rs
rg -n -C 5 --glob '*.rs' 'Frame::Array|FrameVec' src/protocol/parse.rs | head -n 160

printf '%s\n' '--- reachable wrap cases ---'
python3 - <<'PY'
MAX_ARRAY_LENGTH = 1024 * 1024
for n in (32766, 32767, 32768, 65535, 65536, 65537, MAX_ARRAY_LENGTH - 1):
    cast = ((n + 2**15) % 2**16) - 2**15
    given = ((cast + 1 + 2**15) % 2**16) - 2**15
    print(f"args={n}, i16_cast={cast}, release_given={given}, fixed_arity_2_accepts={given == 2}")
PY

Repository: pilotspace/moon

Length of output: 31981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- dispatch entry points and command metadata use ---'
rg -n --glob '*.rs' 'fn (dispatch|handle|execute)|COMMAND_META|metadata::lookup|lookup\(cmd\)' \
  src/server/conn src/command | head -n 220

printf '%s\n' '--- fixed-arity command handlers ---'
rg -n -C 6 --glob '*.rs' 'eq_ignore_ascii_case\(b"(GET|ECHO|PING|SET)"\)|cmd == b"(GET|ECHO|PING|SET)"|args\.len\(\)' \
  src/server/conn/handler_sharded src/server/conn/handler_monoio src/command | head -n 260

Repository: pilotspace/moon

Length of output: 28029


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '55,135p' src/command/mod.rs
rg -n -C 8 --glob '*.rs' 'b"GET"|b"ECHO"|b"SET"' src/command/mod.rs | head -n 220
sed -n '1125,1185p' src/command/mod.rs

Repository: pilotspace/moon

Length of output: 12634


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --glob '*.rs' 'pub fn (get|set|get_readonly)|fn (get|set|get_readonly)' src/command

Repository: pilotspace/moon

Length of output: 3547


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,60p' src/command/string/string_read.rs
sed -n '315,355p' src/command/string/string_read.rs
sed -n '1,55p' src/command/string/string_write.rs

Repository: pilotspace/moon

Length of output: 5342


Use a non-narrowing arity counter.

The parser permits up to 1,048,576 array elements, so this overflow is reachable. In checked builds, args.len() as i16 + 1 overflows at 32,767 arguments. In release builds, the count wraps. For example, GET with 65,537 arguments passes queue-time validation and returns its arity error only during EXEC.

Proposed fix
-    let given = args.len() as i16 + 1;
+    let given = args.len().saturating_add(1);
+    let required = usize::from(meta.arity.unsigned_abs());
     let bad_arity = if meta.arity >= 0 {
-        given != meta.arity
+        given != required
     } else {
-        given < -meta.arity
+        given < required
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// `arity` counts the command name itself, so compare against args + 1.
// Positive = exact; negative = minimum (variadic).
let given = args.len() as i16 + 1;
let bad_arity = if meta.arity >= 0 {
given != meta.arity
} else {
given < -meta.arity
};
// `arity` counts the command name itself, so compare against args + 1.
// Positive = exact; negative = minimum (variadic).
let given = args.len().saturating_add(1);
let required = usize::from(meta.arity.unsigned_abs());
let bad_arity = if meta.arity >= 0 {
given != required
} else {
given < required
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/shared.rs` around lines 1105 - 1112, Update the arity
calculation in the command validation block to use a non-narrowing integer type
capable of representing the parser’s maximum argument count, avoiding the i16
cast and overflow at large inputs. Keep the existing exact-arity and
minimum-arity comparisons with meta.arity correct by using compatible types or
safe conversion.

… array

CI caught three things my local gates did not, all in the lib's own
`#[cfg(test)]` modules — which `cargo test --test <name>` never compiles, and
which a stale clippy incremental cache reported clean.

1. `frame.rs` had a `ParseError::Invalid` initializer missing the new `kind`
   field (E0063). This broke Check, Check (monoio), Check (Windows),
   Check (macOS), and took Client-compat down with the build.

2. The `*-9` leniency was applied to the whole `b'*' | b'~' | b'>'` arm, so
   `~-2` (a RESP3 Set with an invalid count) silently stopped being rejected.
   There is no Redis behaviour to match for `~`/`>` — only `*` is lenient.
   Caught by `test_resp3_negative_set_count`, which is exactly why it exists.

3. `*-1` was folded into the same consume-and-drop path, so it returned
   `Ok(None)` instead of `Frame::Null`. `parse()` also parses REPLIES
   (replication), not just requests, where `*-1` is the canonical null array.
   Caught by `test_parse_null_array`.

Both parser changes are now scoped: `count == -1` yields a null array for all
three types as before; `count < -1` is consumed-and-dropped for `*` only, and
still an error for `~`/`>`.

Verified: 4578 lib + 11 multi_exec + 8 protocol_error, 0 failures.

author: Tin Dang

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔇 Additional comments (5)
src/protocol/parse.rs (2)

36-53: 🗄️ Data Integrity & Integration

⚠️ Unverified finding
Sandbox verification was unavailable.

Verify that ignored multibulk frames do not stall pipelined input.

parse() consumes *-2\r\n and returns Ok(None). The same result also means incomplete input. If src/server/codec.rs waits for another read after None, a complete frame already after the ignored header remains buffered until more network data arrives.

Make parse() continue parsing after it consumes an ignored negative array. Return Ok(None) only when no complete subsequent frame exists.


308-545: LGTM!

.add/tasks/multi-exec-queue-semantics/TASK.md (3)

249-255: LGTM!


256-259: 🎯 Functional Correctness

⚠️ Unverified finding
Sandbox verification was unavailable.

Complete the metadata exception proof before marking this flag resolved.

The contract at Lines 224-230 permits +QUEUED only when COMMAND_META::lookup finds the command and its arity matches. If the dotted-name carve-out allows an unknown dotted command through queue_time_rejection, a typo can receive +QUEUED and fail at EXEC instead of marking the transaction dirty. The listed bare GRAPH omission is also not covered by a dotted-name exception.

Make me10b verify known and unknown dotted names and GRAPH. Register every dispatchable command in COMMAND_META, or document and test an explicit exception that preserves queue-time rejection for unknown commands.


260-261: LGTM!


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e4181bc5-b745-4cb8-8fea-71e2d8b730ee

📥 Commits

Reviewing files that changed from the base of the PR and between e9028ab and 90502db.

📒 Files selected for processing (4)
  • .add/tasks/multi-exec-queue-semantics/TASK.md
  • .add/tasks/protocol-error-lifetime/TASK.md
  • src/protocol/frame.rs
  • src/protocol/parse.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/protocol/frame.rs
  • .add/tasks/protocol-error-lifetime/TASK.md

ERR_STALE_WAIVER in CI: multi_aborts_on_unknown_command and
multi_aborts_on_wrong_arity no longer reproduce, because
multi-exec-queue-semantics fixed them. Retired rather than deleted — both are
now LIVE parity assertions, so EXECABORT cannot silently regress.

The two CONFIG-GET-in-MULTI waivers still reproduce and keep their waiver, now
annotated: the queue gate demonstrably fixed this class for INFO and SUBSCRIBE,
and CONFIG sits BELOW the gate in every handler, so intercept order is not the
explanation. Recorded as not-yet-understood rather than guessed at.

Local: PASS=184 FAIL=0 WAIVED=16 TOTAL=200 (--strict).

author: Tin Dang
The queue gate added for multi-exec-queue-semantics made EVERY non-control
command queue while `in_multi`. That is correct for anything that reaches
`dispatch()` — but `execute_transaction` replays the queue THROUGH `dispatch()`,
and a connection-level intercept never gets there. So commands that worked
fine before the gate started coming back as `-ERR unknown command` from
inside the EXEC array: the client got an error where it used to get data.

Probed all 16 candidates against a live server: 7 broke inside EXEC
(CONFIG, CLIENT, ACL, CLUSTER, SCRIPT, WAIT) and PUBSUB was rejected at
queue time instead, because it is absent from COMMAND_META and has no dot,
so the dotted carve-out misses it. `is_intercept_only()` exempts those from
both the queue gate and queue-time validation.

LATENCY looked like a member and is not. It returns `-ERR unknown command`
OUTSIDE a transaction too — Moon simply does not implement it. Exempting it
would have hidden a genuinely unimplemented command behind a transaction
exemption, and worse, would have let it error without setting `multi_dirty`,
so EXEC would proceed past a command the server does not know. Left out, it
falls through to `queue_time_rejection` and aborts the transaction the way
Redis aborts on any unknown command.

The compat harness's two CONFIG-in-MULTI waivers are what surfaced this;
me10 was strengthened to assert the queued command actually EXECUTES, not
merely that it queued, since the original assertion passed while the
regression was live.

Tested: protocol_error_lifetime 8/8, multi_exec_queue_semantics 12/12
(me7 ignored, pending Frame::NullArray), lib green.

author: Tin Dang

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/conn/shared.rs`:
- Around line 1049-1050: Update the documentation near the queueing explanation
to state that the is_intercept_only classifier exempts PUBSUB from generic MULTI
queueing, rather than claiming queue_time_rejection alone handles it. Keep the
explanation consistent with the surrounding classification logic and handler
call site.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e82c263-f199-47ed-91ad-793f7a3ff5d5

📥 Commits

Reviewing files that changed from the base of the PR and between 0350f26 and bf2a789.

📒 Files selected for processing (4)
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/handler_sharded/mod.rs
  • src/server/conn/shared.rs
  • tests/multi_exec_queue_semantics.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/multi_exec_queue_semantics.rs
  • src/server/conn/handler_sharded/mod.rs
  • src/server/conn/handler_monoio/mod.rs

Comment thread src/server/conn/shared.rs
Comment on lines +1049 to +1050
/// assumption named. It is handled by `queue_time_rejection` consulting this
/// list, not by exempting it from queueing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the PUBSUB queueing explanation.

The queue gate uses is_intercept_only to keep PUBSUB out of generic MULTI queueing. It does not rely only on queue_time_rejection. This sentence conflicts with Line 1035 through Line 1040 and the call site in src/server/conn/handler_sharded/mod.rs, Line 876. State that this classifier exempts PUBSUB from generic queueing.

Proposed documentation fix
-/// It is handled by `queue_time_rejection` consulting this
-/// list, not by exempting it from queueing.
+/// It is handled by exempting it from generic transaction queueing, so
+/// `queue_time_rejection` does not poison the transaction.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// assumption named. It is handled by `queue_time_rejection` consulting this
/// list, not by exempting it from queueing.
/// assumption named. It is handled by exempting it from generic transaction queueing, so
/// `queue_time_rejection` does not poison the transaction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/conn/shared.rs` around lines 1049 - 1050, Update the documentation
near the queueing explanation to state that the is_intercept_only classifier
exempts PUBSUB from generic MULTI queueing, rather than claiming
queue_time_rejection alone handles it. Keep the explanation consistent with the
surrounding classification logic and handler call site.

@TinDang97
TinDang97 merged commit 292269a into main Aug 13, 2026
21 checks passed
@TinDang97
TinDang97 deleted the fix/protocol-error-and-multi-queue-semantics branch August 13, 2026 02:05
TinDang97 added a commit that referenced this pull request Aug 13, 2026
REPO pointed at /Users/tindang/workspaces/tind-repo/moon — the stale second
checkout CLAUDE.md warns about, sitting at #126 (hash-ttl era) while main is
at #472. Every target built, tested and ran ~350 PRs of the wrong code. The
dangerous one is `make ci`, which advertises CI parity and would report green
against a tree nobody is shipping.

Three fixes:

- REPO -> /Volumes/Games/tindang-repo/moon, with a comment naming the stale
  path so the next person does not "restore" it.
- CARGO_TARGET_DIR=target-linux on every cargo target. The VM and the host
  compile the same shared checkout, so without this the Linux ELF artifacts
  clobber the macOS Mach-O ones (CLAUDE.md requires it). Declared with
  `export ... &&` rather than a bare VAR=x prefix, which would only have
  applied to the first command of each `&&` chain — `make ci` and `make clippy`
  each run two cargo invocations.
- `pkill -x moon` -> `pkill -f "$(REPO)/target-linux/release/moon"`. Matching
  by bare process name kills any process called `moon`, not just the one this
  Makefile built.

Verified: `make fmt` runs clean end-to-end in the moon-dev VM against the live
checkout; `make -n ci` shows the export surviving the full && chain.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Aug 13, 2026
#472 landed the code and tests for protocol-error-lifetime and
multi-exec-queue-semantics, but left both phase markers behind — at
`tests` and `ground`. Walks them to done and records a gate for each.

Evidence is taken from merged main, not from the PR branch: both suites
re-run under runtime-monoio (the shipped runtime) AND runtime-tokio.
protocol_error_lifetime 8/8 both ways; multi_exec_queue_semantics 12
passed / 1 ignored both ways.

protocol-error-lifetime is a clean PASS. Its §6 records what was
confirmed rather than what was run: the wiring check is the one that
matters, because Moon has four places that read frames and a handler
still doing `Err(_) => break` compiles perfectly — so all four were
exercised rather than grepped for.

multi-exec-queue-semantics is RISK-ACCEPTED, not PASS. Must #7 is not
met: BLPOP inside MULTI still replies a Null Bulk where Redis replies a
Null Array, and me7 is #[ignore]d rather than passing. The divergence is
not introduced by this task and is not specific to transactions —
`Frame` has no null-array variant at all, so a plain BLPOP that times out
mistypes its reply too. Fixing it means threading Frame::NullArray
through every Frame::Null arm in serialize.rs / resp3.rs, which touches
every reply path and can flip replies that are currently correct. That
earns its own contract; filed as #482, which the gate names as its
ticket. RESP2-only — RESP3 spells both nulls `_`.

me7 stays in the suite, ignored with a reason naming the missing
capability, so the gap is visible to the next reader instead of vanishing
with the assertion. The task that adds the variant un-ignores it as its
own proof.

Also carries the observe deltas forward: the inline-cap divergence above
65 530 B, the write-in-the-valid-prefix ambiguity, and three method
lessons — test the wire not the client, assert the state not the reply
when the bug is that state changed anyway, and discharge a flagged
assumption by test rather than by argument.

Refs #472, #482

author: Tin Dang
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant