Skip to content

fix(security): ACL bypass + silent CLIENT TRACKING failure on the inline GET path - #457

Merged
TinDang97 merged 4 commits into
mainfrom
fix/client-compat-p0-acl-tracking
Aug 9, 2026
Merged

fix(security): ACL bypass + silent CLIENT TRACKING failure on the inline GET path#457
TinDang97 merged 4 commits into
mainfrom
fix/client-compat-p0-acl-tracking

Conversation

@TinDang97

@TinDang97 TinDang97 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

The monoio inline fast path (try_inline_dispatch) answers the plain *2 $3 GET <key> shape straight from the shard map, never entering generic dispatch. Writes were gated on can_inline_writes (which folds in conn.acl_skip_allowed()); reads were gated on nothing. That single omission produced two independent user-visible defects, one of them a security bypass.

Found during a client/SDK compatibility deep review of v0.8.5. Both defects are present on 0.8.4 and 0.8.5 under the default (monoio) runtime.

1. ACL bypass (security)

An authenticated but restricted user reads any key with plain GET. The inline path runs neither the ACL command check nor the ACL key-pattern check, and logs nothing.

Measured against 0.8.5:

user `-@all`          GET secret   -> "value"     (Redis: -NOPERM)
user `+@read ~app:*`  GET outside  -> "value"     (Redis: -NOPERM)
SET/DEL/MGET/HGET/TTL/EXISTS/KEYS/DBSIZE/INFO/FLUSHDB -> -NOPERM  (correctly gated)

Only GET leaks — every other command routes through generic dispatch and is enforced correctly.

Not a single-shard-only quirk. At --shards 4, every key hashing to the connection's own shard leaked (24/160 and 44/160 across a 4-connection sweep — the ~1/N shard-local fraction). At --shards 1, 160/160.

2. CLIENT TRACKING answered +OK and then never invalidated

The same path also skips tracking::invalidation::track_read_keys, so a client-side-caching client's own GETs were never registered and nothing ever invalidated them — stale reads forever, with no error surfaced.

Isolated without a rebuild: an identical trial reading via MGET (not inline-eligible) received the invalidate; via GET it received nothing. At --shards 4 delivery depended on whether the key hashed off the reader's shard, which is also why mset_invalidates_every_second_arg_key was flaky — measured failing 2 of 3 runs on unmodified 0.8.5.

Separately, CLIENT TRACKING ON BCAST with no PREFIX registered nothing: all three handlers only call register_prefix inside for prefix in &prefixes, so an empty list was a silent no-op. Redis treats prefix-less BCAST as "every key".

Changes

  • src/server/conn/blocking.rstry_inline_dispatch{,_loop} take a new can_inline_reads; is_get now requires it, mirroring is_set/can_inline_writes.

  • src/server/conn/handler_monoio/mod.rscan_inline_reads = acl_skip_allowed() && !conn.tracking_state.enabled. acl_skip_allowed() is hoisted and shared with the write gate, so the hot path pays nothing it did not already pay (one Acquire load + a compare, previously computed for the write gate alone).

    Deliberately not gated on the process-global tracking_active(): only a connection's own reads populate its own invalidation set, so one caching client must not push every other connection off the fast path. Writes keep the global gate — a non-tracking writer must still invalidate everyone else.

  • src/command/client.rsparse_tracking_args normalises prefix-less BCAST to the empty prefix, which TrackingTable's key.starts_with(prefix) match treats as all keys. One change covers all three handlers.

The tokio handlers were never affected — they gate at handler_single.rs and handler_sharded/mod.rs.

Why CI did not catch this

Every CI test job builds tokio. The defect is monoio-only, so no existing job could have observed it. This is a structural blind spot, not a coverage gap in any one test — filed as a follow-up (see below).

The new tests are written so they pass trivially under tokio and actually exercise the bug under monoio; both are documented in the test files.

Verification

  • tests/acl_inline_read_enforcement.rs (new) — deny-all and key-pattern users at --shards 1 and --shards 4. Red first (160/160, 24/160, 160/160, 44/160 leaks), green after.
  • tests/client_tracking_invalidation.rs — adds inline-GET tracking at --shards 1 plus BCAST with and without PREFIX. The pre-existing flaky mset_invalidates_every_second_arg_key is now deterministic (4/4).
  • src/server/conn/tests.rs — unit guard that a GET is refused when can_inline_reads is false (zero bytes consumed, buffer untouched), plus the new arg threaded through 16 call sites.
  • Wire diff vs real Redis 8.6.1 — identical on all ACL and tracking probes at both shard counts.
  • monoio release suite (moon-dev VM): 4568 pass / 2 fail; both fail identically on base ab4c393e (monoio_yield_overhead_is_microscopic — pre-existing, filed separately; and one load flake that clears in isolation).
  • tokio suite (CI parity): 124 suites ok.
  • fmt + clippy ×2 (default and runtime-tokio,jemalloc): green.

Fast path preserved

Measured directly via moon_dispatch_path_total{path="local_inline"} rather than inferred from latency — this proves identical path selection, not just similar timing:

connection GETs inlined
unrestricted default user 2000 / 2000
unrestricted + --requirepass 2000 / 2000
restricted ACL user 0 / 2000 (correct values via generic dispatch)
CLIENT TRACKING on 0 / 2000 (correct values + invalidations)

Linux A/B on the VM, one server alive at a time, alternating order, 5 samples each, medians: c=1 P=1 +4.95% (within run-to-run noise — the base column itself swings wider than that), c=8 P=16 0.00%.

Follow-ups (not in this PR)

  1. monoio_yield_overhead_is_microscopic fails on main (578ms for 200 yields vs a <100ms bound) — the v2-perf cooperative_yield self-pipe fix appears to have regressed to timer-park cost. Invisible because it is monoio-only.
  2. cargo clippy --all-targets fails on pre-existing lints in tests/busy_poll_idle.rs and src/io/fd_table.rs; CI omits --all-targets, so test-code lints never gate.
  3. A monoio CI test job. This defect class — a security check that exists on two of three dispatch paths — is invisible to the current matrix by construction.

The remaining ~20 client-compat findings from the review (RESP3 type fidelity, HELLO identity, pub/sub push frames, cluster bootstrap, INFO telemetry) are scoped as a separate v0.9 milestone.

Summary by CodeRabbit

  • Security

    • ACL restrictions are now consistently enforced for fast-path reads.
    • Access rules are honored across single- and multi-shard setups.
  • Bug Fixes

    • Client tracking now records read invalidations correctly.
    • CLIENT TRACKING ON BCAST without a prefix now tracks all keys.
    • Prefix-based tracking properly excludes unrelated keys.
    • GET commands inside MULTI are correctly queued until EXEC.

…ine GET path

The monoio inline fast path (`try_inline_dispatch`) answers the plain
`*2 $3 GET <key>` shape straight from the shard map, never entering generic
dispatch. Writes were gated on `can_inline_writes` (which folds in
`conn.acl_skip_allowed()`); reads were gated on nothing. That one omission
produced two independent user-visible defects.

1. ACL bypass (security). An authenticated but restricted user read any key
   with plain GET — the inline path runs neither the ACL command check nor
   the ACL key-pattern check, and logs nothing. Measured on 0.8.4/0.8.5:

     user `-@all`          GET secret  -> "value"   (Redis: -NOPERM)
     user `+@READ ~app:*`  GET outside -> "value"   (Redis: -NOPERM)
     SET/DEL/MGET/HGET/TTL/EXISTS/... -> -NOPERM    (correctly gated)

   Not single-shard-only: at `--shards 4` every key hashing to the
   connection's own shard leaked (24/160 and 44/160 across a 4-connection
   sweep); `--shards 1` leaked 160/160.

2. CLIENT TRACKING answered +OK and then never invalidated. The same path
   also skips `tracking::invalidation::track_read_keys`, so a client-side
   caching client's own GETs were never registered and nothing invalidated
   them — stale reads forever. An identical trial reading via MGET (not
   inline-eligible) received the invalidate; via GET it received nothing.
   At `--shards 4` delivery depended on whether the key hashed off the
   reader's shard, which is also why `mset_invalidates_every_second_arg_key`
   was flaky (measured failing 2 of 3 runs on unmodified 0.8.5).

Fixes:

- `blocking.rs`: `try_inline_dispatch{,_loop}` take `can_inline_reads`;
  `is_get` now requires it, mirroring `is_set`/`can_inline_writes`.
- `handler_monoio/mod.rs`: `can_inline_reads = acl_skip_allowed() &&
  !conn.tracking_state.enabled`. `acl_skip_allowed()` is hoisted and shared
  with the write gate, so the hot path pays nothing it did not already pay.
  Deliberately NOT gated on the process-global `tracking_active()`: only a
  connection's own reads populate its invalidation set, so one caching
  client must not push every other connection off the fast path (writes keep
  the global gate — a non-tracking writer must invalidate everyone else).
- `command/client.rs`: `CLIENT TRACKING ON BCAST` with no PREFIX registered
  nothing, because the handlers only call `register_prefix` inside
  `for prefix in &prefixes`. Redis treats prefix-less BCAST as "every key";
  `parse_tracking_args` now normalises it to the empty prefix, which
  `TrackingTable`'s `key.starts_with(prefix)` match treats as all keys. One
  change covers all three handlers.

The tokio handlers were never affected — they gate at handler_single.rs and
handler_sharded/mod.rs. Every CI test job builds tokio, which is why none of
this was visible in CI.

Verification:
- `tests/acl_inline_read_enforcement.rs` (new): deny-all and key-pattern
  users at `--shards 1` and `--shards 4`; red first (160/160, 24/160,
  160/160, 44/160 leaks), green after.
- `tests/client_tracking_invalidation.rs`: adds inline-GET tracking at
  `--shards 1` and BCAST with/without PREFIX; the pre-existing flaky
  `mset_invalidates_every_second_arg_key` is now deterministic (4/4).
- `src/server/conn/tests.rs`: unit guard that a GET is refused when
  `can_inline_reads` is false, plus the new arg threaded through 16 call
  sites.
- Wire diff vs real Redis 8.6.1: identical on all ACL and tracking probes at
  both shard counts.
- Fast path preserved, measured via
  `moon_dispatch_path_total{path="local_inline"}`: 2000/2000 GETs still
  inlined for the unrestricted default user with AND without
  `--requirepass`; 0/2000 for restricted and tracking connections, which
  still return correct values via generic dispatch.

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 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bbf67f2-0aa1-495c-9d26-e5b5797cbd4e

📥 Commits

Reviewing files that changed from the base of the PR and between cac19c4 and 1723707.

📒 Files selected for processing (3)
  • tests/acl_inline_read_enforcement.rs
  • tests/client_tracking_invalidation.rs
  • tests/multi_queues_inline_get.rs
📝 Walkthrough

Walkthrough

Monoio inline GET dispatch now checks ACL, transaction, and tracking state before using the fast path. Restricted or tracked reads use generic dispatch. Prefixless tracking broadcasts match all keys. Regression tests cover ACL, transactions, tracking, and shard configurations.

Changes

Monoio inline dispatch

Layer / File(s) Summary
Gate inline reads and writes
src/server/conn/blocking.rs, src/server/conn/handler_monoio/mod.rs, src/server/conn/tests.rs, CHANGELOG.md
Separate read and write permissions control inline dispatch. Read inlining is disabled for restricted, tracked, transactional, cluster, and other unsupported cases.
Validate ACL enforcement
tests/acl_inline_read_enforcement.rs
Integration tests verify deny-all and key-pattern ACL behavior with one and four shards.
Preserve transaction queuing
tests/multi_queues_inline_get.rs, CHANGELOG.md
GET queues inside MULTI and returns its value through EXEC. Tests retain queueing for MGET and SET, and direct handling outside transactions.
Normalize and test tracking invalidation
src/command/client.rs, tests/client_tracking_invalidation.rs, CHANGELOG.md
Prefixless BCAST uses an empty prefix to match all keys. Tests cover tracked inline GET invalidation and prefix filtering.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MonoioHandler
  participant InlineDispatcher
  participant GenericDispatcher
  participant TrackingState
  Client->>MonoioHandler: Send GET
  MonoioHandler->>TrackingState: Check transaction and tracking state
  MonoioHandler->>InlineDispatcher: Check ACL and read eligibility
  alt Eligible connection
    InlineDispatcher-->>Client: Return inline GET response
  else Restricted, tracked, or transactional connection
    MonoioHandler->>GenericDispatcher: Dispatch GET generically
    GenericDispatcher-->>Client: Queue or register the read
    GenericDispatcher-->>Client: Return checked response
  end
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main security and tracking defects fixed in the monoio inline GET path.
Description check ✅ Passed The description is detailed, on topic, and covers the changes, testing, performance impact, and follow-up work, although it omits the template headings.
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/client-compat-p0-acl-tracking

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: 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 `@tests/acl_inline_read_enforcement.rs`:
- Around line 56-109: Update spawn_moon to return Moon directly and fail after
the readiness timeout instead of returning None. Preserve the existing startup
probing, but include useful startup diagnostics when the process fails to become
ready, and update each caller to use the returned Moon without silently exiting
so the security tests always exercise a real server.
🪄 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: d58e491b-0540-4271-9d4f-700f49192def

📥 Commits

Reviewing files that changed from the base of the PR and between ab4c393 and b2e9821.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/command/client.rs
  • src/server/conn/blocking.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/tests.rs
  • tests/acl_inline_read_enforcement.rs
  • tests/client_tracking_invalidation.rs

Comment thread tests/acl_inline_read_enforcement.rs Outdated
…ine read path

Found by the new raw-RESP compatibility harness on its first run against a real
redis-server, after this branch's ACL and tracking fixes were already in place.
Same gate, same one-line shape, and the most client-visible of the three.

  MULTI     -> +OK
  GET k     -> $1 v      Redis: +QUEUED
  EXEC      -> *0        Redis: *1[$1 v]
  MGET k    -> +QUEUED   control: not inline-eligible, always queued correctly

The inline fast path answers `*2 $3 GET` from the shard map without entering
generic dispatch, so it never observes `conn.in_multi`. `can_inline_writes`
already carried `!conn.in_multi` — which is why `SET` queued correctly all
along — but `can_inline_reads`, added earlier on this branch for the ACL and
tracking gaps, did not.

The consequence is worse than a stale read. The client receives a reply of the
wrong kind for its position in the exchange (a value where the protocol says
`+QUEUED`), and then an `EXEC` that silently omits the read entirely. A
redis-py or go-redis transaction built on MULTI/EXEC gets an empty result set
for an exchange it believes succeeded — no error is raised anywhere.

Reproduced at `--shards 1` and `--shards 4` (a multi-shard connection leaks
only for keys hashing to its own shard, which are the inline-eligible ones).
Pre-existing on main: before this branch `is_get` was ungated entirely.

Fix: `can_inline_reads` now carries `!conn.in_multi`, sharing the condition
with the write gate.

Verification:
- `tests/multi_queues_inline_get.rs` (new): queue-and-exec at `--shards 1` and
  `--shards 4`, red first (`got "$2\r\nv0\r\n"`). Plus three controls that pin
  what must NOT change — `MGET` still queues, `SET` still queues, and a plain
  `GET` outside a transaction still takes the fast path, including immediately
  after a completed MULTI.
- Fast path retained, measured via `moon_dispatch_path_total{path=
  "local_inline"}`: 2000/2000 GETs still inlined for the default user, with
  `--requirepass`, and after a completed transaction — so `in_multi` clears and
  the connection returns to the fast path rather than being permanently
  demoted.
- Sibling suites still green: 4 ACL + 8 tracking + 5 multi = 17.
- fmt clean; clippy green on both CI configurations.

Monoio-only, like the other two: every CI test job builds tokio, so no existing
job could observe any of them.

author: Tin Dang
@TinDang97

Copy link
Copy Markdown
Collaborator Author

Added: a third defect on the same gate (cac19c4f)

While building the client-compat harness for the follow-up milestone, its first run against a real redis-server surfaced a third instance of this exact bug — one that survived the fix in this PR as originally opened.

GET inside MULTI is executed, not queued.

MULTI     -> +OK
GET k     -> $1 v      Redis: +QUEUED
EXEC      -> *0        Redis: *1[$1 v]
MGET k    -> +QUEUED   control: not inline-eligible, queued correctly all along

The inline path never observes conn.in_multi. can_inline_writes already carried !conn.in_multi — which is why SET queued correctly — but can_inline_reads, added by this PR, did not.

This is arguably the most client-visible of the three. It isn't a stale read: the client gets a reply of the wrong kind for its position in the exchange, then an EXEC that silently omits the read. A redis-py or go-redis transaction returns an empty result set for an exchange it believes succeeded, with no error raised.

Reproduced at --shards 1 and --shards 4. Pre-existing on main (where is_get was ungated entirely).

Fix: one condition — can_inline_reads now carries !conn.in_multi, shared with the write gate.

Verification:

  • tests/multi_queues_inline_get.rs (new) — red first (got "$2\r\nv0\r\n"), plus three controls pinning what must not change: MGET still queues, SET still queues, and a plain GET outside a transaction still takes the fast path including immediately after a completed MULTI.
  • Fast path retained, measured via moon_dispatch_path_total{path="local_inline"}: 2000/2000 GETs still inlined for the default user, with --requirepass, and after a completed transaction — so in_multi clears rather than permanently demoting the connection.
  • Sibling suites green: 4 ACL + 8 tracking + 5 multi = 17. fmt clean, clippy green on both CI configurations.

Also worth flagging for the follow-up milestone, not fixed here: CONFIG GET inside MULTI is a separate instance of the same class (EXEC returns *0), and RESP3 conversion is skipped for EXEC inner replies — SMEMBERS is a Set outside MULTI and a flat Array inside it. Both are tracked with reproductions in the harness manifest.

@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: 2

🤖 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 `@tests/multi_queues_inline_get.rs`:
- Around line 50-100: Update spawn_moon so readiness failure fails the test
instead of returning None and allowing callers to exit successfully. Change its
failure behavior to panic or return a Result, then update every caller to
propagate or expect that failure while preserving the real Moon server startup
and readiness checks.
- Around line 116-127: Update the test helper method Resp::cmd to read and parse
complete RESP frames instead of treating one TcpStream::read result as the full
response. Reuse or add a test-side RESP parser that handles simple strings, bulk
strings, and nested arrays, and return the fully framed reply before MULTI/EXEC
assertions run.
🪄 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: 1f4b0a0f-2c7c-4848-9f11-8fc3fda284e7

📥 Commits

Reviewing files that changed from the base of the PR and between b2e9821 and cac19c4.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/server/conn/handler_monoio/mod.rs
  • tests/multi_queues_inline_get.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/server/conn/handler_monoio/mod.rs
  • CHANGELOG.md

Comment thread tests/multi_queues_inline_get.rs Outdated
Comment on lines +116 to +127
fn cmd(&mut self, args: &[&str]) -> String {
let mut out = format!("*{}\r\n", args.len()).into_bytes();
for a in args {
out.extend_from_slice(format!("${}\r\n{a}\r\n", a.len()).as_bytes());
}
self.stream.write_all(&out).expect("write");
let mut buf = [0u8; 4096];
match self.stream.read(&mut buf) {
Ok(n) => String::from_utf8_lossy(&buf[..n]).into_owned(),
Err(e) => format!("<read error: {e}>"),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for an existing framed RESP test helper before adding another parser.
fd -t f -e rs tests src \
  | xargs -r rg -n -C 3 'BufReader|read_exact|read_line|RESP|Resp' || true

Repository: pilotspace/moon

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant test file and nearby usage.
if [ -f tests/multi_queues_inline_get.rs ]; then
  echo "== outline =="
  ast-grep outline tests/multi_queues_inline_get.rs || true
  echo "== lines 1-180 =="
  sed -n '1,180p' tests/multi_queues_inline_get.rs | nl -ba
fi

echo "== all occurrences of Resp::cmd and cmd helpers =="
rg -n -C 4 'Resp::cmd|fn cmd|read_exact|BufReader|RESP|resp' tests src || true

Repository: pilotspace/moon

Length of output: 821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file existence =="
test -f tests/multi_queues_inline_get.rs && echo "exists" || echo "missing"

echo "== relevant lines =="
sed -n '1,280p' tests/multi_queues_inline_get.rs

echo "== occurrences =="
rg -n -C 4 'Resp::cmd|fn cmd|read_exact|BufReader|RESP|resp' tests src || true

Repository: pilotspace/moon

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tool availability =="
for tool in sed cat awk python3 git; do
  command -v "$tool" >/dev/null 2>&{1} && echo "$tool found" || echo "$tool missing"
done

echo "== locate file =="
find . -path './tests/multi_queues_inline_get.rs' -type f -print || true

echo "== relevant lines =="
sed -n '1,280p' tests/multi_queues_inline_get.rs

echo "== occurrences =="
rg -n -C 4 'Resp::cmd|fn cmd|read_exact|BufReader|RESP|resp' tests src || truern

Repository: pilotspace/moon

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files tests/multi_queues_inline_get.rs

echo "== targeted file lines 1-260 =="
sed -n '1,260p' tests/multi_queues_inline_get.rs

echo "== targeted occurrences =="
rg -n -C 3 '\bResp::cmd\b|\bcmd\b\([^)]*\(|read_exact|BufReader|RESP|resp' tests/multi_queues_inline_get.rs || true

Repository: pilotspace/moon

Length of output: 8348


Read complete RESP frames before assertions.

TcpStream::read may return only part of a response. Resp::cmd converts that partial buffer to a complete reply, so nested MULTI/EXEC checks can accept truncated data or fail intermittently. Use a framed RESP reader that reads simple strings, bulk strings, and nested arrays to completion, or reuse a test-side RESP parser.

🤖 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_queues_inline_get.rs` around lines 116 - 127, Update the test
helper method Resp::cmd to read and parse complete RESP frames instead of
treating one TcpStream::read result as the full response. Reuse or add a
test-side RESP parser that handles simple strings, bulk strings, and nested
arrays, and return the fully framed reply before MULTI/EXEC assertions run.

Review finding on the new ACL suite, applied to all three suites this branch
relies on. `spawn_moon` returned `Option<Moon>` and every caller did
`let Some(m) = spawn_moon(..) else { return }`, so a server that never became
ready turned the test into a no-op that still reported PASS.

That is the wrong failure mode anywhere, and specifically wrong here: these
three suites are the regression guard for a security bypass, a silent
CLIENT TRACKING failure, and a transaction-correctness bug. A guard that can
quietly stop guarding is how the original defects survived two releases.

- `spawn_moon` now returns `Moon` and panics when the readiness deadline
  expires. Callers use the server directly; no early return remains.
- The panic carries diagnostics rather than just the symptom: the child's
  status (exited with N / still running but never answered PING / unavailable)
  and moon's captured stderr. stderr was `Stdio::null()` and is now redirected
  to `moon.stderr` in the per-test temp dir. Verified the redirect surfaces a
  real cause — spawning with a bad flag yields
  "error: unexpected argument '--this-flag-does-not-exist' found"
  where the old path printed only "did not become ready".
- The startup probing itself is unchanged: same `common::spawn_listening` bind
  handling, same 10s PING poll at 100ms.
- `client_tracking_invalidation.rs`: `moon_binary()` dropped its `Option`,
  which was already vestigial — every return path was `Some(..)`.

Validation: acl_inline_read_enforcement 4/4, client_tracking_invalidation 8/8,
multi_queues_inline_get 5/5. fmt clean; clippy green on both CI configurations
and with --all-targets.

author: Tin Dang
…e sufficient

Review of this PR raised a case the suite did not cover. `can_inline_reads` is
computed ONCE per read-buffer iteration, before `try_inline_dispatch_loop` walks
the buffer — so when `MULTI` and the `GET` after it arrive in the SAME write,
the gate is evaluated while `conn.in_multi` is still false.

Probed directly: the pipelined case is correct, and measurably still correct
with the `!conn.in_multi` gate REMOVED. The inline loop bails at the first
command that is not a plain GET/SET, so a buffer containing `MULTI` hands its
whole remainder to generic dispatch and never re-enters the inline path. The
defect only reproduces when `MULTI` and the `GET` arrive in separate reads,
which the existing tests already cover.

So this test does not guard the gate — it guards the structural property that
makes the gate sufficient. Teaching the inline loop to skip past commands it
does not recognise, rather than bailing out, would silently reopen the bug for
pipelined clients, and only this test would notice. The doc comment says exactly
that, including the measurement, because a test whose value is misdescribed is
worse than no test.

Also asserts the converse: a `GET` BEFORE the `MULTI` in the same write is still
served inline, so the fix did not degrade to "disable the fast path whenever a
buffer contains MULTI".

6/6 in this suite; ACL 4/4 and tracking 8/8 unaffected. fmt clean, clippy green
with --all-targets.

author: Tin Dang
@TinDang97
TinDang97 merged commit 59c7045 into main Aug 9, 2026
8 checks passed
TinDang97 added a commit that referenced this pull request Aug 9, 2026
…0-9-client-compat

The ADD tracker had drifted a full release train behind the repo: it still
reported `v0-6-0-release` as the active milestone while the repo had shipped
v0.6.0, v0.7.0, v0.8.0, and v0.8.5. `add.py check` was red on two records.

Re-sync:

- `shardslice-migration`: retire the RISK-ACCEPTED waiver that expired
  2026-08-01. Its condition — the follow-up "cross-shard-read-acceleration
  (observe)" — was met in v2-performance / v2-2 xshard-read-validation
  (PRs #177/#178/#179): the C2 reply-side path recovers 38-49% of the
  cross-shard read penalty and the remainder is the ~10us irreducible hop.
  Gate raised RISK-ACCEPTED -> PASS, with the retired waiver kept verbatim in
  `waiver_retired` so the record is auditable rather than erased.

  Re-gated by direct state edit because `add.py gate` refuses with
  `tripwire_missing`. That guard is firing on a schema gap, not a finding: NO
  task in this project carries a tamper snapshot (all 17 predate the tripwire),
  so every task would trip it identically — `shardslice-migration` is simply the
  only one that needed its gate rewritten. Recorded as such in the note.

- `fts-posting-rank-tf`: `depends_on: ["none"]` was a literal-string typo that
  `check` correctly read as an unresolvable dependency. Now `[]`.

- `v3-3-vector-kv-polish`, `v3-4-kv-correctness`, `v3-5-write-path-durability`,
  `v0-6-0-release`: four empty shells, scaffolded but never populated, because
  delivery moved to PR-driven waves and never routed back through ADD. Marked
  `superseded` (not `done`) with a pointer to the PRs that actually shipped each
  scope. `add.py milestone-done` refuses a zero-task milestone — "nothing
  attached -> nothing proven" — and that refusal is right; claiming `done` would
  launder a gate that was never run. `superseded` is the honest record and only
  `done` is load-bearing anywhere in the engine.

`add.py check`: 2 failed -> 89 passed, 0 failed, 0 warnings.

New milestone `v0-9-client-compat` (production stage, now active), from the
client/SDK deep review of v0.8.5. The two P0s found by that review ship
separately as the v0.8.6 hotfix (PR #457); this milestone covers the remaining
~20 findings — the surface that stops an unmodified redis-py / go-redis /
ioredis / monitoring agent from treating Moon as a drop-in.

Eight tasks, breadth-first: `client-compat-harness` and `monoio-ci-coverage`
have no dependencies and land first, because every other task cites the harness
as its verifier and a verifier merged alongside the fix it verifies proves
nothing. Then `client-identity-introspection`, `resp3-type-fidelity`,
`pubsub-resp3-push`, `cluster-client-bootstrap`, `info-observability`, and
`sdk-wire-form-fixes`.

The shared decisions are what the review taught: real Redis is the oracle (never
Moon's own expectation — the defects were found exactly where Moon tested Moon);
RESP2 and RESP3 are both first-class; a command must not change shape by context
(standalone vs MULTI vs pipeline); registered implies reachable; and behavior
lands on all three dispatch paths, since a check present on two of three is the
precise shape of the v0.8.6 P0.

11/11 exit criteria cite a verifier — the milestone is goal auto-ready.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Aug 9, 2026
… redis-server

Moon already had a Redis comparison suite. It could not see reply types.

`scripts/test-commands.sh` drives both servers through `redis-cli`, which renders
replies to human-readable text before any assertion runs — `assert_match_ttl`
even does `tr -d '(integer) '` — and `grep -c -- '-3 '` over its 2426 lines
returns 0, so the entire RESP3 surface was never compared at all. Every
assertion in `tests/redis_compat.rs` (968 lines) is Moon against a hand-written
expectation, with no `redis-server` in the loop. Between them, a wrong reply
TYPE was invisible. That is why ~22 type-level defects reached v0.8.5.

This adds a differ that speaks RESP on a raw socket, so the type byte survives
to the assertion:

- compares in a fixed order — TYPE, then SHAPE, then VALUE — and names which
  one diverged, because "wrong reply type" and "right type, wrong value" are
  different bugs that must not be reported as one
- runs the full {RESP2, RESP3} x {standalone, MULTI/EXEC, pipeline} matrix, so
  a reply that changes shape by context is observable. This is not theoretical:
  `apply_resp3_conversion` is called from 11 sites across 3 handlers rather
  than one choke point, and the matrix immediately proved the EXEC path is one
  of the misses.
- normalizes only what is declared, per entry: exact | sorted | type_only |
  numeric_tolerance | ignore_value. No global fuzzy match — that would rebuild
  the blindness being removed.
- compares errors on the code (first token) and never on message text
- refuses rather than skips: no redis-server is ERR_NO_ORACLE with exit 2. A
  differential harness with nothing to differ against would report a green that
  means nothing.

Findings on the first full run (oracle redis 8.6.1, moon 0.8.5+, 152
comparisons): 94 pass, 58 waived, 0 unexplained. Every waiver is a real,
reproduced divergence carrying a required reason and the task that owns its
fix, so CI is a ratchet — a NEW divergence fails the job, and `--strict` fails
the moment a waived one is fixed and its waiver goes stale.

Two of those findings are new, neither visible to any existing test:

1. GET inside MULTI is not queued. On the monoio inline fast path it executes
   immediately: the client receives `$1 v` where Redis sends `+QUEUED`, and
   EXEC then answers `*0` instead of `*1[$1 v]`. `MGET`, which is not
   inline-eligible, queues correctly. Root cause is the same gate as the v0.8.6
   P0 (PR #457): `can_inline_writes` carries `!conn.in_multi`,
   `can_inline_reads` does not. Pre-existing on main and NOT fixed by #457.
   `CONFIG GET` inside MULTI is a second, separate instance of the same class.

2. RESP3 conversion is not applied to EXEC inner replies. SMEMBERS is a Set
   outside MULTI and a flat Array inside it; ZSCORE is a Double outside and a
   Bulk inside. SISMEMBER passes inside MULTI only because two bugs cancel —
   it is over-converted to Boolean outside, and unconverted inside.

Both are monoio-only. Every CI test job builds tokio, so neither could have
been caught by the existing matrix — the same structural blind spot that hid
the v0.8.6 ACL bypass.

`--info-manifest` additionally enumerates 34 INFO fields the standard
monitoring stack reads that Moon does not emit (run_id, tcp_port,
uptime_in_seconds, keyspace_hits/misses, evicted_keys, maxmemory*,
instantaneous_ops_per_sec, ...) — a monitoring agent loses those series
silently, which is worse than an error because the dashboard simply goes blank.

Tests are stdlib `unittest`, not pytest: pytest is absent from the moon-dev VM
that runs the self-hosted job, and a PR-gating job must not depend on a package
hand-installed into a runner that gets rebuilt. 33 unit tests (codec,
comparator, all five policies, every reject code) + 19 end-to-end tests against
real servers, all red before the implementation existed.

New CI job `client-compat` builds moon with the DEFAULT (monoio) runtime — the
one clients actually hit — then runs the unit suite, the e2e suite, the strict
diff, and the INFO coverage report, uploading the machine-readable record.

Also scaffolds ADD milestone v0-9-client-compat: this task plus the seven that
consume its output.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Aug 9, 2026
… a real redis-server (#458)

Moon already had a Redis comparison suite. It could not see reply types.

`scripts/test-commands.sh` drives both servers through `redis-cli`, which renders
replies to human-readable text before any assertion runs — `assert_match_ttl`
literally does `tr -d '(integer) '` — and `grep -c -- '-3 '` over its 2426 lines
returns 0, so the entire RESP3 surface was never compared. Every assertion in
`tests/redis_compat.rs` (968 lines) is Moon against a hand-written expectation
with no redis-server in the loop. Between them, a wrong reply TYPE was
structurally invisible. That is why ~22 type-level defects reached v0.8.5.

This adds a differ that speaks RESP on a raw socket, so the type byte survives
to the assertion:

- compares TYPE, then SHAPE, then VALUE, naming which one diverged — "wrong
  reply type" and "right type, wrong value" are different bugs
- runs the full {RESP2, RESP3} x {standalone, MULTI/EXEC, pipeline} matrix, so a
  reply that changes shape by context is observable. Not theoretical:
  `apply_resp3_conversion` is called from 11 sites across 3 handlers rather than
  one choke point, and the matrix immediately proved the EXEC path is a miss.
- normalizes only what is declared per entry (exact | sorted | type_only |
  numeric_tolerance | ignore_value). No global fuzzy match — that would rebuild
  the blindness being removed.
- compares errors on the code (first token), never on message text
- refuses rather than skips: no redis-server is ERR_NO_ORACLE, exit 2

First full run (oracle redis 8.6.1, 152 comparisons): 98 pass, 54 waived, 0
unexplained. Every waiver carries a required reason and names the task that owns
its fix, so CI is a ratchet — a NEW divergence fails the job, and `--strict`
fails the moment a waived one is fixed and its waiver goes stale.

It found two defects nothing else could see. `GET` inside `MULTI` was executed
rather than queued (fixed in #457 as a direct result), and RESP3 conversion is
skipped for EXEC inner replies — SMEMBERS is a Set outside MULTI and a flat
Array inside, ZSCORE is Double outside and Bulk inside, and SISMEMBER passes
inside MULTI only because two bugs cancel. `--info-manifest` additionally
enumerates 33 INFO fields the standard monitoring stack reads that Moon does not
emit; a monitoring agent loses those series silently, which is worse than an
error because the dashboard simply goes blank.

The stale-waiver mechanism then proved itself unprompted: once #457's fix
landed, `--strict` failed on its own with `ERR_STALE_WAIVER: waivers no longer
reproduce: multi_get_must_queue, error_wrongtype`. Both are now unwaived
permanent guards.

Verification: 33 unit tests (codec, comparator, all five policies, every reject
code) + 20 end-to-end tests against real servers, all red before implementation.
An adversarial refute-read found and fixed two ways the harness could report
results it had not earned — the byte-identity invariant was VACUOUS in the MULTI
context (`sent` was reconstructed from the same argv for both servers, so it
could never fail; now recorded from the socket), and `--info-manifest` blamed
Moon for a wrong pin (it checked only Moon; a field the oracle also lacks is now
"fix the pin, not moon"). With that in place the pinned list validates itself:
33 findings, 0 bad pins.

Tests are stdlib `unittest`, not pytest — pytest is absent from the moon-dev VM
that runs the self-hosted job, and a PR-gating job must not depend on a package
hand-installed into a runner that gets rebuilt. The new `client-compat` job
builds moon with the DEFAULT (monoio) runtime, the one clients actually hit;
every existing CI test job builds tokio, which is why all three defects found
this week were invisible.

Also included: the ADD milestone scaffold for v0-9-client-compat (this task plus
the seven that consume its output) and this task's verify record, which files
nine evidence-backed spec deltas plus two honesty deltas.

Full CI dispatched on this branch before merge: all 8 jobs green, including
macOS and Windows, which pull requests normally skip.
@TinDang97
TinDang97 deleted the fix/client-compat-p0-acl-tracking branch August 9, 2026 09:43
TinDang97 added a commit that referenced this pull request Aug 10, 2026
… not the shipped one (#464)

* ci(test): run the monoio suite — CI was testing the fallback runtime, not the shipped one

Every CI job that EXECUTED tests did so under `--no-default-features
--features runtime-tokio,...`. Moon's default feature set is runtime-monoio,
and that is what ships on Linux. The result: 26 monoio integration test files
and 30 monoio-gated src/ files were unreachable by CI, and the documented local
gate in CLAUDE.md ("Local CI Parity", which runs BOTH suites) was strictly
stronger than CI itself.

That gap is not theoretical. The v0.8.6 inline-GET ACL bypass (#457) was wrong
only on the monoio dispatch path and shipped green. The RESP3 type-fidelity
work (#463) had to hand-verify one of its two enqueue sites locally, because CI
structurally could not see it.

Adds `check-monoio`: self-hosted Linux runner (the only place monoio's io_uring
driver executes at all), default feature set, `cargo nextest run --profile ci`,
its own CARGO_TARGET_DIR, no continue-on-error, MOON_NO_URING deliberately
unset.

`--profile ci` is load-bearing rather than incidental: the suite has a known
load-sensitive flake class, a bare `cargo test` has no retries, and an
intermittently-red required job gets disabled -- which is worse than no job,
because it still looks like coverage. The existing profile's retries=2 absorbs
it while still reporting FLAKY, so the signal survives.

Measured on moon-dev (kernel 6.17) before landing:
  5145 passed, 1 flaky, 244 skipped, exit 0, 80.3s of test time.

VERIFIED BY NEGATIVE CONTROL, not by inspection. A CI-config change can be
green and still be worthless, so the claim was tested directly: a deliberate
defect injected on `try_inline_dispatch` (cfg(feature = "runtime-monoio"), so
tokio cannot reach it) making inline GET answer "$6\r\nBROKEN\r\n":

  tokio  (CI before this change) : multi_queues_inline_get 6 passed  <- ships green
  monoio (the new job)           : multi_queues_inline_get 3 FAILED  <- caught

Reverted immediately; zero residual markers, and the suite back to 6/6.

tests/ci_covers_monoio.rs guards the job itself, because the failure mode of CI
coverage is silent -- a job that stops running or is switched to the wrong
feature set looks exactly like a green build. It fails on a wrong feature set,
continue-on-error, a bare `cargo test`, a shared target dir with the tokio job,
or removal of tokio coverage.

NOTE FOR THE REVIEWER: adding the job makes it RUN, not BLOCK. It must be added
to branch protection to gate merges; until then it is advisory.

Refs: .add/tasks/monoio-ci-coverage (gate PASS), milestone v0-9-client-compat
author: Tin Dang

* ci(fix): the monoio job was running io_uring disabled — MOON_NO_URING was a workflow-level global

`check-monoio` exists for one reason: to execute the io_uring driver that
actually ships on Linux. Its comment said "MOON_NO_URING is deliberately NOT
set here", its job block was clean, and `ci_covers_monoio.rs` asserted that
block stayed clean. All three were checking the wrong scope.

`MOON_NO_URING: "1"` sat in the workflow-level `env:`, which merges into every
job — and a job cannot unset an inherited key (an empty value is still a set
variable to `env::var_os`). So the one job whose entire premise was io_uring
ran with io_uring force-disabled, and had done since the job was written.

`monoio_yield_overhead_is_microscopic` reported it: every `cooperative_yield()`
fell through `uring_active()` to the `sleep(ZERO)` timer park, 290ms for 200
yields = 1.45ms/yield, against a 100ms budget. That read like a load-sensitive
flake on a shared runner. It was not — same binary on moon-dev, the env var as
the only delta:

  io_uring active    -> ok, 0.00s
  MOON_NO_URING=1    -> FAILED, 0.59s

Deterministic in both directions. The test was right and the config was wrong.

Fix: MOON_NO_URING moves out of workflow-level env onto the jobs that want it —
`check` (tokio; the io_uring bridge floods errors under load), `memory-steady-
state` (real server on a GitHub-hosted runner where io_uring may be seccomp-
restricted), and `client-compat` (kept so its recorded waiver baseline stays
comparable; the differ probes wire shapes, not drivers — commented as such, so
it is documented rather than silent). Dropped from macOS/Windows/msrv/console/
lint, where it was inherited dead config: no io_uring on those platforms and
those jobs execute no Moon.

`ci_covers_monoio.rs` gains the assertion it was missing — the workflow-level
`env:` block must not define MOON_NO_URING either. Red before the yaml change
for exactly that reason, green after; 5/5.

Verified on moon-dev with the job's exact fixed config (default features,
io_uring live, nextest --profile ci), which no CI run had ever exercised.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Aug 12, 2026
…eding a live bug

Two findings from the client-compat harness, which is the check that failed on
PR #471 — both real, neither visible to the 14 raw-RESP tests in this task's own
suite.

1. ROLE executed at QUEUE time inside MULTI.

The first cut intercepted ROLE at the connection layer, ahead of the MULTI
queueing step, because its answer lives on ConnectionContext.repl_state rather
than in the Database that dispatch() receives. So `MULTI; ROLE; EXEC` replied
the role array immediately and EXEC then returned `*0`. The damage is worse than
a wrong reply: the command silently vanishes from the EXEC array, so every LATER
result shifts down one index and a client reads another command's answer as this
one's. Redis queues ROLE like any other command.

Fixed by answering ROLE from the shared dispatch table instead, reading the
process-global replication handle that INFO already uses and that every entry
point registers (main.rs, listener.rs, embedded.rs). That is the only placement
where a queued ROLE can work at all, since EXEC replays the queue through
dispatch() — and it deleted all three per-handler intercepts, so ROLE now lives
in exactly two places instead of five. Verified on the wire: `MULTI; ROLE; PING;
EXEC` returns `*2` with the role array first and +PONG still last, and a live
replica still reports `slave` through the global handle. ci15 pins the
alignment; it fails under both prior states (`*0`, and `*1` with an unknown-
command error).

Nothing in this task's suite exercised a connection-layer command INSIDE a
transaction, which is the coverage lesson: a new intercept has to be tested in
MULTI as well as standalone, because its POSITION relative to queueing is the
thing that can be wrong.

2. The harness's own divergence test needed a live Moon defect to pass.

test_a_diverging_entry_exits_one_and_names_the_divergence borrowed a real bug as
its fixture, so it FAILED whenever someone FIXED that bug — a test that punishes
the fix. Three fixtures had already been burned this way (GET-inside-MULTI #457,
SISMEMBER RESP3 #463, and COMMAND COUNT, retired by this very task, which is
what turned the PR red). The durable fix was already designed and filed as #461,
so it is implemented here rather than rotating to a fourth defect: a test-only
`inject_moon_reply` hook fabricates the divergence, with a guard test asserting
the shipped manifest never uses one. Proven load-bearing by disabling the hook,
which turns the test red.

Manifest: the identity_command_count and identity_role waivers are retired as
fixed, and COMMAND INFO / COMMAND GETKEYS / RESET are added as live entries.
COMMAND INFO carries a new, accurate waiver — its 10-field SHAPE now matches,
but acl_categories is thin (@string where Redis says @READ @string @fast),
key_specs is empty, and under RESP3 Redis types flags/acl_categories as Sets and
key_specs entries as Maps where Moon emits Arrays. Owners recorded.

Harness result against redis-server 8.6.1: PASS=181 FAIL=0 WAIVED=19 (was
PASS=179 FAIL=8 before this commit).

Gates: fmt, clippy (default + tokio/jemalloc, --all-targets), the identity suite
(15 scenarios), and both compat suites (34 differ + 20 e2e) green. Note the
unreachable-pattern warning caught during this work: (4, b'r') already existed
in dispatch_inner for RPOP, so the first ROLE arm was dead code and EXEC still
answered unknown command until it was merged into the existing arm.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Aug 12, 2026
…#471)

* docs(add): close watch-cas-transactions at PASS, record what the scope gate did not prove

The code shipped in #470; this closes the task record.

The first `gate PASS` was refused with `scope_violation` naming 7020 files,
including `.github/workflows/*.yml` and `Cargo.lock` — paths this task never
touched. Cause: the scope baseline is a whole-tree byte snapshot taken at the
tests->build crossing, with no git awareness. Between that snapshot and the
gate, five dependabot PRs merged into main (#420, #417, #346, #358, #359) and
target/ was rebuilt, so every unrelated change read as "this task touched it".

Rather than re-snapshot quietly, §6 now records:
  - the task's ACTUAL change set from `git show --name-only 151a185 1c12af0`,
    every path of which is inside the declared §5 Scope;
  - the two entries that needed naming instead of glossing — `conn/mod.rs`
    (one line, `pub mod watch;`, undeclared) and `.add/tooling/add.py`
    (25 lines of engine fix carried in from an earlier session, genuinely
    outside §5);
  - that the re-taken baseline is the post-merge tree, so the scope walk at
    this gate compares the tree against itself and proves nothing. The
    evidence that the build stayed in scope is the git file list, not the
    green gate.

Also ticks the §6 verify checklist against evidence already gathered: the
stubbed-counter A/B (reverting only next_birth_version turns 4 new tests red,
so the green is earned), the 41->1 tokio failure delta traced to a diskfull
$TMPDIR rather than to the change, and the 9/9 dispatched matrix including the
Windows/macOS/console jobs no PR run executes.

Adds .add/.gitignore, which `add.py init` scaffolds but this project never got.
Without it the two 40MB scope-snapshot.json sidecars are commit candidates;
they are regenerable working state, and the durable scope declaration is the
state.json anchor. One 436K sidecar is already tracked from an older task and
is left alone.

Method delta for the next loop: as built, any task gated after unrelated merges
land inherits an unfalsifiable scope violation. The anchor wants to be a git
tree-ish, or the walk should skip gitignored paths and diff the merge-base.

author: Tin Dang

* feat(command): derive COMMAND/ROLE/RESET/HELLO from real server state

Moon's client-identity surface answered from compile-time constants instead of
from the server. The failures were invisible by eye because redis-cli renders
`:0` and `*0` identically as "0", so every assertion here is on raw RESP bytes.

COMMAND and COMMAND COUNT each returned the OTHER'S TYPE: bare COMMAND replied
`:0` (an Integer where an Array belongs) and COMMAND COUNT replied `*0` (an
Array where an Integer belongs). COMMAND INFO/DOCS/LIST/GETKEYS all replied an
empty array. A driver that builds its command map at connect time does not read
that as "unsupported" — it reads it as a protocol violation. All six now derive
from the COMMAND_META phf registry (263 commands), so registering a command is
what makes it introspectable and there is no second table to drift.

ROLE and RESET were unknown commands. RESET was registered in the metadata
table with full flags while dispatch rejected it — the same advertise-then-
reject class as WATCH/UNWATCH before v0.8.6 — and a partial RESET existed only
inside handler_sharded's subscribe-mode loop, so it worked if you happened to
be subscribed on one runtime and nowhere else. RESET's "default state" is taken
from restore_migrated_state(None, ..), the same function ConnectionState::new
uses, so it cannot drift from what a fresh connection means by default.

HELLO contradicted INFO replication on the same connection: hello_acl built
`mode`/`standalone` and `role`/`master` as literals, so a replica announced
itself a master. Both fields now read ReplicationState/ClusterState. Redis
deliberately uses three vocabularies for this one fact (HELLO -> replica,
INFO -> slave, ROLE -> slave); that was measured against a live replica pair on
redis-server 8.6.1, and it corrected this task's own frozen contract, which had
said "slave" everywhere. The test was made stronger rather than matched to the
code: ci12 asserts all three surfaces agree, each in its own vocabulary, plus a
negative assertion that HELLO no longer claims master.

CLIENT INFO / CLIENT LIST reported the literal `laddr=127.0.0.1:0` from inside
the format string, so every client on every listener showed port 0. It now
carries the real local address.

Wired on all three connection handlers plus the inline fast path. ROLE and
RESET cannot ride the shared dispatch table — their answers live on
ConnectionContext/ReplicationState, not in the Database that dispatch()
receives — so each handler needs its own intercept, and handler_single had
neither. That handler is reachable only via listener::run_with_shutdown (an
in-process tokio API used by a few test suites; main.rs and embedded.rs both
route through run_sharded), which is precisely why it had drifted, and why an
A/B caught the gap: reverting the fix there left ci12 green. ci14 drives that
handler in-process and goes red with `-ERR unknown command 'ROLE'` without it.

Tests: 14 raw-RESP scenarios in tests/client_identity_introspection.rs, with
parity legs on the monoio and sharded handlers and the inline path. Startup is
serialised across the suite's threads — 13 servers initialising data dirs at
once left one unable to answer PING inside 30s at ~1 run in 8; a longer timeout
would have been slower and still flaky, and a shared OnceLock server would have
leaked a live moon past exit because statics are never dropped. 32/32 green at
--test-threads=13 after the fix.

Found and filed, not folded in: response batches are serialised at flush time
using the FINAL protocol version, so a protocol-changing command retro-encodes
earlier replies in the same batch (`HELLO 3` alone -> `%7`; `HELLO 3` +
`HELLO 2` in one write -> `*14`). Pre-existing — that reproducer touches none
of this code — but RESET, which reverts the protocol by contract, adds a second
trigger. Filed as ADD task batch-protocol-version-fidelity with the measurement
table. MONITOR was split out to monitor-command-feed at freeze: it is a stream
rather than a reply, the only item touching the per-command hot path, and the
only one exposing other clients' credentials.

Gates: cargo fmt --check, clippy (default + tokio/jemalloc, --all-targets),
cargo test --release, and the full tokio CI-parity suite all green.

author: Tin Dang

* fix(command): queue ROLE inside MULTI, and stop the compat harness needing a live bug

Two findings from the client-compat harness, which is the check that failed on
PR #471 — both real, neither visible to the 14 raw-RESP tests in this task's own
suite.

1. ROLE executed at QUEUE time inside MULTI.

The first cut intercepted ROLE at the connection layer, ahead of the MULTI
queueing step, because its answer lives on ConnectionContext.repl_state rather
than in the Database that dispatch() receives. So `MULTI; ROLE; EXEC` replied
the role array immediately and EXEC then returned `*0`. The damage is worse than
a wrong reply: the command silently vanishes from the EXEC array, so every LATER
result shifts down one index and a client reads another command's answer as this
one's. Redis queues ROLE like any other command.

Fixed by answering ROLE from the shared dispatch table instead, reading the
process-global replication handle that INFO already uses and that every entry
point registers (main.rs, listener.rs, embedded.rs). That is the only placement
where a queued ROLE can work at all, since EXEC replays the queue through
dispatch() — and it deleted all three per-handler intercepts, so ROLE now lives
in exactly two places instead of five. Verified on the wire: `MULTI; ROLE; PING;
EXEC` returns `*2` with the role array first and +PONG still last, and a live
replica still reports `slave` through the global handle. ci15 pins the
alignment; it fails under both prior states (`*0`, and `*1` with an unknown-
command error).

Nothing in this task's suite exercised a connection-layer command INSIDE a
transaction, which is the coverage lesson: a new intercept has to be tested in
MULTI as well as standalone, because its POSITION relative to queueing is the
thing that can be wrong.

2. The harness's own divergence test needed a live Moon defect to pass.

test_a_diverging_entry_exits_one_and_names_the_divergence borrowed a real bug as
its fixture, so it FAILED whenever someone FIXED that bug — a test that punishes
the fix. Three fixtures had already been burned this way (GET-inside-MULTI #457,
SISMEMBER RESP3 #463, and COMMAND COUNT, retired by this very task, which is
what turned the PR red). The durable fix was already designed and filed as #461,
so it is implemented here rather than rotating to a fourth defect: a test-only
`inject_moon_reply` hook fabricates the divergence, with a guard test asserting
the shipped manifest never uses one. Proven load-bearing by disabling the hook,
which turns the test red.

Manifest: the identity_command_count and identity_role waivers are retired as
fixed, and COMMAND INFO / COMMAND GETKEYS / RESET are added as live entries.
COMMAND INFO carries a new, accurate waiver — its 10-field SHAPE now matches,
but acl_categories is thin (@string where Redis says @READ @string @fast),
key_specs is empty, and under RESP3 Redis types flags/acl_categories as Sets and
key_specs entries as Maps where Moon emits Arrays. Owners recorded.

Harness result against redis-server 8.6.1: PASS=181 FAIL=0 WAIVED=19 (was
PASS=179 FAIL=8 before this commit).

Gates: fmt, clippy (default + tokio/jemalloc, --all-targets), the identity suite
(15 scenarios), and both compat suites (34 differ + 20 e2e) green. Note the
unreachable-pattern warning caught during this work: (4, b'r') already existed
in dispatch_inner for RPOP, so the first ROLE arm was dead code and EXEC still
answered unknown command until it was merged into the existing arm.

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