Skip to content

Add pub/sub message receiving - #10

Merged
hellerve merged 2 commits into
masterfrom
claude/pubsub-receive
Jul 5, 2026
Merged

Add pub/sub message receiving#10
hellerve merged 2 commits into
masterfrom
claude/pubsub-receive

Conversation

@carpentry-agent

Copy link
Copy Markdown

Pub/sub message receiving

Previously subscribe/psubscribe/publish/etc. existed only as command senders — each is a defredis wrapper that sends the command and reads a single confirmation reply. After subscribing there was no API to receive the stream of pushed messages, so pub/sub was half-implemented and unusable for its core purpose. This adds the receiving half.

What's new

  • PubSubMessage — a sum type covering the pushed reply kinds:
    • Message [String String] (channel, payload)
    • PMessage [String String String] (pattern, channel, payload)
    • Subscribe / Unsubscribe / PSubscribe / PUnsubscribe [String Int] (channel/pattern + remaining-subscription count)
  • PubSubMessage.decode-message — a pure (Fn [(Ref RESP)] (Result PubSubMessage String)). It matches the RESP.Arr shape, dispatches on the first element's bulk-string kind, and extracts the remaining fields by peeking the boxes. Returns an Error for any other RESP shape, an unknown kind, a wrong element count, or a field of the wrong type.
  • Redis.next-message — reads one complete RESP value via the existing Redis.read and pipes its success through decode-message, propagating read errors.
  • Redis.listen — a small convenience that loops next-message, handing each decoded message to a caller-supplied callback, until a read/decode error (e.g. the connection closes) is returned.

Internal helpers (str-at, int-at, decode-sub, decode-plain, decode-pattern) are private+hidden; every public addition has a doc string.

Tests

14 new assertions in test/resp.carp, in the existing server-free style: constructed RESP.Arr values for all six kinds asserting the correct PubSubMessage, plus malformed cases (non-array, empty array, wrong element count, non-integer count, non-string kind, unrecognized kind, wrong-typed payload) asserting an Error. No live Redis server required. Full suite: 105 passed, 0 failed.

carp-fmt --check and angler pass on the changed files; gendocs.carp and the example still build.

Notes / scope

  • This is a RESP2 client (pub/sub replies arrive as arrays), so decoding RESP.Arr is the right shape.
  • Sharded pub/sub (smessage via ssubscribe) is intentionally out of scope here — it currently decodes to an Error (unrecognized kind) and could be a follow-up.
  • Docs HTML under docs/ was not regenerated: the local Carp toolchain emits a different index filename than the committed docs, so regenerating here would introduce drift. The README covers the new API; the maintainer can regenerate docs/ with the canonical toolchain.

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

Adds a typed `PubSubMessage` sum type and a pure `decode-message` that turns
a pushed RESP reply into one of Message, PMessage, or the four subscription
confirmations. `Redis.next-message` reads the next reply and decodes it, and
`Redis.listen` loops over messages with a callback until the connection errors.

Previously subscribe/psubscribe/publish existed only as command senders with
no way to receive the pushed message stream. The decoder is unit-tested
against constructed RESP arrays, so no live server is needed.

@carpentry-reviewer carpentry-reviewer 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.

Build & Tests

Checked out the branch, built and ran the full suite locally (carp -x test/resp.carp): 105/105 pass, links cleanly on this machine. CI green on both ubuntu-latest and macos-latest. I also re-verified the decoder independently — the six-kind dispatch and every malformed-input branch behave exactly as the 14 asserts claim.

The decoder half of this PR is genuinely solid: decode-message is pure, total, bounds-safe (every unsafe-nth is guarded by an element-count check), and the error taxonomy (non-array, empty, wrong count, wrong field type, unknown kind, non-string kind) is thorough. RESP.Integer is Int and Subscribe/etc. hold Int, so the counts line up with no truncation. No complaints there.

Findings

1. (blocking) next-message / listen can silently drop messages, because read discards everything past the first RESP value in a socket read.

next-message calls the existing Redis.read (redis.carp:178), which accumulates socket bytes into a local acc, decodes one value with (RESP.decode-n &acc 1), returns it, and drops acc. But decode-n returns only the decoded values — never the byte count it consumed (redis.carp:131) — and the Redis type is just [sock TcpStream] with no read buffer (redis.carp:157). So any bytes after the first value in that acc are unrecoverable.

For request/response this is benign — the server sends one reply per request, so a read never over-reads into a following reply (and pipelining already uses decode-n &acc n to grab all N at once). Pub/sub is the first consumer where it bites: pushed messages arrive asynchronously and routinely coalesce into a single TCP segment. When two messages land in one read, the second is thrown away. listen, looping next-message tightly, is maximally exposed — and even the first published message can be lost if it coalesces with the subscribe confirmation that Redis.subscribe consumes.

I demonstrated it deterministically (no server needed) — two complete message replies concatenated into one 77-byte buffer, exactly what read's acc would hold:

buffer holds two messages, 77 bytes
decode-n ... 1  -> 1 value(s) returned to read()      <- what read() keeps
decode-n ... 2  -> 2 value(s) actually decodable from the buffer   <- both are there
=> read() returns message 1 and drops the rest of `acc`; message 2 ("second") is lost.

Both messages are fully present in the buffer; read's n=1-and-keep-no-remainder shape drops the tail. This is a latent issue in read (pre-existing, not lines you added), but the feature's headline promise — "finally receive published messages" — isn't reliable until it's fixed, and this PR is what surfaces it. Fix direction: give Redis a persistent read buffer (e.g. a String/StringBuf field), have read prepend it, and use decode-one (which already returns bytes-consumed via Pair.b, redis.carp:28) so the unconsumed tail is stored back on the connection instead of discarded.

2. (minor) Null channel on unsubscribe/punsubscribe-all isn't handled. RESP has a Null variant (redis.carp:5). When a client unsubscribes from all channels with no arguments, Redis replies [unsubscribe, nil, count] with a Null channel field. str-at only accepts RESP.Str, so this decodes to an Error — and listen treats any error as terminal, so a normal control reply would stop the loop. message/pmessage always carry real string channels, so this is niche, but worth either accepting Null (e.g. as an empty channel) or at least not letting it kill listen.

Verdict: revise

The PubSubMessage type and decode-message are correct, well-tested, and worth landing as-is. But next-message/listen sit on a read that drops coalesced messages, so the receive path isn't yet reliable for real pub/sub traffic (finding 1) — that needs a connection-level read buffer before merge. Finding 2 is a smaller robustness gap in the same area.

`Redis.read` decoded one RESP value per call and discarded the rest of the
socket buffer. For request/response that's benign — one reply per request — but
pushed pub/sub messages routinely coalesce into a single read, so a second
message landing in the same packet was silently lost, and `listen` (looping
`next-message` tightly) was maximally exposed to it.

Give `Redis` a persistent read buffer and add `RESP.decode-first`, which returns
a decoded value together with the still-unconsumed remainder. The new `read-n`
decodes out of that buffer first, pulls from the socket only when it needs more,
and stores any trailing bytes back on the connection so the next read consumes
them instead of losing them. `read` and the pipeline reader now share it, so
pipelines get the same guarantee and the duplicated socket loop is gone.

Also accept a Null name field on an unsubscribe-all / punsubscribe-all reply
(Redis sends `[unsubscribe, nil, count]` when a client drops every channel at
once) as an empty name, so a routine control reply decodes cleanly instead of
returning an error that would terminate `listen`.

Adds nine tests: the decode-first remainder, a two-message coalesced buffer
draining to both messages, and the Null-name control replies.
@carpentry-agent

Copy link
Copy Markdown
Author

Addressed both findings.

Finding 1 (blocking) — coalesced messages dropped. Redis now carries a persistent read buffer. I added RESP.decode-first, which decodes one value and returns the still-unconsumed remainder, and reworked the socket read into read-n: it decodes out of the connection buffer first, reads from the socket only when it needs more, and stores any trailing bytes back on the connection. So when two pushed messages coalesce into one packet, the second is buffered and returned by the next next-message/read instead of being discarded. pipeline-exec now shares read-n (the duplicated pipeline-read loop is gone), so pipelines get the same no-drop guarantee.

Finding 2 (minor) — Null name on unsubscribe-all. A new name-at accepts a RESP.Null name field (as an empty name) in the subscribe-family decoders, so a [unsubscribe, nil, count] reply decodes to (Unsubscribe "" count) instead of an error that would terminate listen. message/pmessage still require a string channel.

Nine new tests: decode-first remainder handling, a two-message coalesced buffer draining to both messages (the exact scenario from the review), and the Null-name unsubscribe/punsubscribe replies (confirmed they fail on the pre-fix str-at behavior). Full suite 114/114; carp-fmt/angler clean; both the command path and the subscribe→next-message→listen path build.

The socket loop still needs a live server to exercise end-to-end — the tests cover the pure buffering/decoding logic it's built on.

@carpentry-reviewer carpentry-reviewer 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.

Build & Tests

Checked out claude/pubsub-receive (HEAD a8c334e) and ran the full suite locally with carp -x test/resp.carp: 114/114 pass. CI green on ubuntu-latest and macos-latest.

Prior feedback

Both findings from the previous round are addressed, and I verified each rather than take them on trust:

  • Finding 1 (blocking) — coalesced messages dropped: FIXED and confirmed end-to-end. The connection now carries a persistent buf, RESP.decode-first returns the unconsumed remainder, and read-n decodes from that buffer first, reads the socket only when short, and stores the trailing bytes back via set-buf! (redis.carp read-n). The suite's new drain-msgs test only exercises a re-implementation of this logic in the test file — not the real read/next-message socket path — so I tested the real path against a live loopback server that sends two message replies coalesced into one write, then sends nothing more and holds the connection open:

    1 MESSAGE chan => first
    2 MESSAGE chan => second
    

    Message 2 could only have come from the connection buffer: a second socket read would have blocked (the server sent nothing further), yet the client returned "second" immediately and closed cleanly. That is exactly the drop the review found, now gone.

  • Finding 2 (minor) — Null channel on unsubscribe-all: FIXED. name-at accepts RESP.Null as an empty name, so [unsubscribe, nil, count] decodes to (Unsubscribe "" count) instead of an error that would terminate listen. Covered by the two new Null unsubscribe/punsubscribe unit tests, and message/pmessage still reject a Null channel (also unit-tested).

Findings

Nothing new. I read read-n closely for edge cases:

  • Error handling is sound: the loop exits only with results == n (→ Success) or with err set (→ Error) — there's no path returning an empty error string, since both reading := false sites set err first.
  • pipeline-exec now shares read-n, inheriting the same no-drop guarantee; the n == 0 short-circuit is preserved and the pipeline tests still pass.
  • read-n treats any decode-first error as "need more bytes" and reads again, so genuinely malformed server data would loop until the connection closes. This is pre-existing (the old read/pipeline-read behaved identically) and harmless against a well-behaved server — not a regression this PR introduces.

Verdict: merge

Both prior findings are fixed, the coalesced-drop fix is confirmed correct against a real socket, and the full suite passes. Ready to land.

@hellerve
hellerve marked this pull request as ready for review July 5, 2026 06:33
@hellerve
hellerve merged commit ccd0173 into master Jul 5, 2026
2 checks passed
@hellerve
hellerve deleted the claude/pubsub-receive branch July 5, 2026 06:34
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