Add pub/sub message receiving - #10
Conversation
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.
There was a problem hiding this comment.
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.
|
Addressed both findings. Finding 1 (blocking) — coalesced messages dropped. Finding 2 (minor) — Null name on unsubscribe-all. A new Nine new tests: 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. |
There was a problem hiding this comment.
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-firstreturns the unconsumed remainder, andread-ndecodes from that buffer first, reads the socket only when short, and stores the trailing bytes back viaset-buf!(redis.carpread-n). The suite's newdrain-msgstest only exercises a re-implementation of this logic in the test file — not the realread/next-messagesocket path — so I tested the real path against a live loopback server that sends twomessagereplies coalesced into one write, then sends nothing more and holds the connection open:1 MESSAGE chan => first 2 MESSAGE chan => secondMessage 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-atacceptsRESP.Nullas an empty name, so[unsubscribe, nil, count]decodes to(Unsubscribe "" count)instead of an error that would terminatelisten. Covered by the two new Null unsubscribe/punsubscribe unit tests, andmessage/pmessagestill 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 witherrset (→ Error) — there's no path returning an empty error string, since bothreading := falsesites seterrfirst. pipeline-execnow sharesread-n, inheriting the same no-drop guarantee; then == 0short-circuit is preserved and the pipeline tests still pass.read-ntreats anydecode-firsterror as "need more bytes" and reads again, so genuinely malformed server data would loop until the connection closes. This is pre-existing (the oldread/pipeline-readbehaved 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.
Pub/sub message receiving
Previously
subscribe/psubscribe/publish/etc. existed only as command senders — each is adefrediswrapper 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 theRESP.Arrshape, dispatches on the first element's bulk-string kind, and extracts the remaining fields by peeking the boxes. Returns anErrorfor 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 existingRedis.readand pipes its success throughdecode-message, propagating read errors.Redis.listen— a small convenience that loopsnext-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) areprivate+hidden; every public addition has a doc string.Tests
14 new assertions in
test/resp.carp, in the existing server-free style: constructedRESP.Arrvalues for all six kinds asserting the correctPubSubMessage, plus malformed cases (non-array, empty array, wrong element count, non-integer count, non-string kind, unrecognized kind, wrong-typed payload) asserting anError. No live Redis server required. Full suite: 105 passed, 0 failed.carp-fmt --checkandanglerpass on the changed files;gendocs.carpand the example still build.Notes / scope
RESP.Arris the right shape.smessageviassubscribe) is intentionally out of scope here — it currently decodes to anError(unrecognized kind) and could be a follow-up.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 regeneratedocs/with the canonical toolchain.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.