Skip to content

Commit dc14c85

Browse files
authored
Add RESP3 protocol support (#14)
Implement RESP3 (Redis 6.0+) protocol negotiation and all RESP3 value types, completing Phase 6 of the implementation plan. Protocol layer: extend the RespValue union with 8 new types (RespBoolean, RespDouble, RespBigNumber, RespBulkError, RespVerbatimString, RespMap, RespSet, RespPush). Parser handles all RESP3 type bytes in both the completeness check and destructive parse. New _map_size method handles the map wire format (count = number of pairs, elements = count * 2). Session layer: add _SessionNegotiating state for HELLO 3 handshake with automatic RESP2 fallback when the server doesn't support RESP3. HELLO failure with a password configured falls through to standard AUTH. Add on_push to the state interface for routing RESP3 push messages separately from regular responses. _SessionSubscribed dispatches both RespArray (RESP2) and RespPush (RESP3) pub/sub messages through a shared _dispatch_pubsub_values method. ConnectInfo gains protocol (ProtocolVersion) and username fields for RESP3 and ACL support. _ResponseHandler routes RespPush before the general RespValue match arm so push messages reach on_push rather than on_response. Test coverage includes a Redis 5 container for HELLO fallback integration testing and unit tests for the HELLO/AUTH command construction primitives. Design: #2
1 parent 1202906 commit dc14c85

17 files changed

Lines changed: 1489 additions & 63 deletions

.github/workflows/breakage-against-ponyc-latest.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ jobs:
2929
--health-interval 10s
3030
--health-timeout 5s
3131
--health-retries 5
32+
redis-resp2:
33+
image: redis:5
34+
options: >-
35+
--health-cmd "redis-cli ping"
36+
--health-interval 10s
37+
--health-timeout 5s
38+
--health-retries 5
3239
steps:
3340
- uses: actions/checkout@v4.1.1
3441
- name: Unit tests
@@ -40,6 +47,8 @@ jobs:
4047
REDIS_PORT: 6379
4148
REDIS_SSL_HOST: redis-ssl
4249
REDIS_SSL_PORT: 6379
50+
REDIS_RESP2_HOST: redis-resp2
51+
REDIS_RESP2_PORT: 6379
4352
- name: Build examples
4453
run: make build-examples config=debug ssl=libressl
4554
- name: Send alert on failure

.github/workflows/pr.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ jobs:
5555
--health-interval 10s
5656
--health-timeout 5s
5757
--health-retries 5
58+
redis-resp2:
59+
image: redis:5
60+
options: >-
61+
--health-cmd "redis-cli ping"
62+
--health-interval 10s
63+
--health-timeout 5s
64+
--health-retries 5
5865
steps:
5966
- uses: actions/checkout@v4.1.1
6067
- name: Unit tests
@@ -66,5 +73,7 @@ jobs:
6673
REDIS_PORT: 6379
6774
REDIS_SSL_HOST: redis-ssl
6875
REDIS_SSL_PORT: 6379
76+
REDIS_RESP2_HOST: redis-resp2
77+
REDIS_RESP2_PORT: 6379
6978
- name: Build examples
7079
run: make build-examples config=debug ssl=libressl

CLAUDE.md

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ make # build and run all tests (requires Redis running)
77
make unit-tests # run unit tests only (no Redis needed)
88
make integration-tests # run integration tests only (requires Redis)
99
make build-examples # build example programs
10-
make start-redis # start plaintext + SSL Redis in Docker
11-
make stop-redis # stop and remove both Redis containers
10+
make start-redis # start plaintext, SSL, and RESP2-only Redis in Docker
11+
make stop-redis # stop and remove all Redis containers
1212
make clean # clean build artifacts
1313
```
1414

@@ -30,22 +30,24 @@ make stop-redis
3030

3131
Package: `redis`
3232

33-
### RESP2 Protocol Layer
33+
### Protocol Layer
3434

35-
- `RespValue` (union type in `resp_value.pony`): Core type for RESP2 wire format values. Union of `RespSimpleString`, `RespBulkString`, `RespInteger`, `RespArray`, `RespError`, `RespNull`.
35+
- `RespValue` (union type in `resp_value.pony`): Core type for RESP2/RESP3 wire format values. Union of `RespSimpleString`, `RespBulkString`, `RespInteger`, `RespArray`, `RespError`, `RespNull`, `RespBoolean`, `RespDouble`, `RespBigNumber`, `RespBulkError`, `RespVerbatimString`, `RespMap`, `RespSet`, `RespPush`.
3636
- `RespMalformed` (in `resp_value.pony`): Parser error type indicating invalid RESP data. Not part of `RespValue` — represents a protocol violation, not a valid value.
37-
- `_RespParser` (in `_resp_parser.pony`): Two-pass parser — peek-based completeness check, then destructive parse. Returns `(RespValue | None | RespMalformed)` from a `buffered.Reader`.
37+
- `_RespParser` (in `_resp_parser.pony`): Two-pass parser — peek-based completeness check, then destructive parse. Returns `(RespValue | None | RespMalformed)` from a `buffered.Reader`. Supports all RESP2 and RESP3 type bytes.
3838
- `_RespSerializer` (in `_resp_serializer.pony`): Serializes commands (`Array[ByteSeq] val`) to RESP2 wire format.
39+
- `ProtocolVersion` (type alias in `protocol_version.pony`): `(Resp2 | Resp3)`. Controls which protocol the session negotiates on connect.
3940

4041
### Session Layer
4142

42-
- `Session` (actor in `session.pony`): Main entry point. Manages connection lifecycle and pub/sub via a state machine. Implements `lori.TCPConnectionActor & lori.ClientLifecycleEventReceiver`. All state machine classes (`_SessionUnopened`, `_SessionConnected`, `_SessionReady`, `_SessionSubscribed`, `_SessionClosed`) are in `session.pony`, following the postgres pattern.
43-
- `ConnectInfo` (in `connect_info.pony`): Connection configuration (host, port, optional password, SSL mode).
43+
- `Session` (actor in `session.pony`): Main entry point. Manages connection lifecycle and pub/sub via a state machine. Implements `lori.TCPConnectionActor & lori.ClientLifecycleEventReceiver`. All state machine classes (`_SessionUnopened`, `_SessionNegotiating`, `_SessionConnected`, `_SessionReady`, `_SessionSubscribed`, `_SessionClosed`) are in `session.pony`, following the postgres pattern.
44+
- `ConnectInfo` (in `connect_info.pony`): Connection configuration (host, port, optional password, SSL mode, optional username, protocol version).
4445
- `SessionStatusNotify` (in `session_status_notify.pony`): Lifecycle callback interface. All callbacks have default no-op implementations. Callbacks: `redis_session_connected`, `redis_session_connection_failed`, `redis_session_ready`, `redis_session_authentication_failed`, `redis_session_closed`.
4546
- `ResultReceiver` (in `result_receiver.pony`): Command response callback interface. Callbacks: `redis_response`, `redis_command_failed`.
4647
- `SubscriptionNotify` (in `subscription_notify.pony`): Pub/sub callback interface. All callbacks have default no-op implementations. Callbacks: `redis_subscribed`, `redis_unsubscribed`, `redis_message`, `redis_psubscribed`, `redis_punsubscribed`, `redis_pmessage`.
4748
- `ClientError` (in `client_error.pony`): Client-side error trait with `SessionNotReady`, `SessionClosed`, and `SessionInSubscribedMode` primitives.
48-
- `_ResponseHandler` (in `_response_handler.pony`): Loops `_RespParser` over a `buffered.Reader`, delivering parsed `RespValue`s to the current state. Shuts down on `RespMalformed`.
49+
- `_ResponseHandler` (in `_response_handler.pony`): Loops `_RespParser` over a `buffered.Reader`, routing `RespPush` to `on_push` and other `RespValue`s to `on_response`. Shuts down on `RespMalformed`.
50+
- `_BuildHelloCommand` / `_BuildAuthCommand` (primitives in `session.pony`): Build HELLO 3 and AUTH commands for protocol negotiation and authentication.
4951
- `_IllegalState` / `_Unreachable` (in `_mort.pony`): Primitives for detecting impossible states.
5052

5153
### SSL/TLS
@@ -60,13 +62,18 @@ Package: `redis`
6062
- `_ClosedState`: Mixin for the terminal state — rejects or no-ops all operations.
6163
- `_ConnectedState`: Mixin for states with a readbuf — handles `on_received` and `_ResponseHandler` dispatch.
6264
- `_NotReadyForCommands`: Mixin that rejects `execute()` with `SessionNotReady`.
63-
- `_NotSubscribed`: Mixin that no-ops `subscribe`, `unsubscribe`, `psubscribe`, `punsubscribe` for states where pub/sub is not applicable.
65+
- `_NotSubscribed`: Mixin that no-ops `subscribe`, `unsubscribe`, `psubscribe`, `punsubscribe` for states where pub/sub is not applicable. Also provides a no-op `on_push` for states that don't handle push messages (only trait that provides `on_push`, to avoid diamond inheritance in `_SessionClosed`).
6466

6567
### State Machine
6668

6769
```
68-
_SessionUnopened ──on_connected──► _SessionConnected (if password)
69-
──on_connected──► _SessionReady (if no password)
70+
_SessionUnopened ──on_connected──► _SessionNegotiating (if Resp3)
71+
──on_connected──► _SessionConnected (if Resp2 + password)
72+
──on_connected──► _SessionReady (if Resp2, no password)
73+
74+
_SessionNegotiating ──HELLO map──► _SessionReady
75+
──HELLO error──► _SessionConnected (if password, send AUTH)
76+
──HELLO error──► _SessionReady (if no password)
7077
7178
_SessionConnected ──AUTH OK──► _SessionReady
7279
──AUTH error──► _SessionClosed
@@ -80,24 +87,25 @@ _SessionSubscribed ──unsub count 0──► _SessionReady
8087

8188
Commands are pipelined in `_SessionReady`: each `execute()` call sends the command immediately over the wire without waiting for prior responses. Responses are matched to receivers in FIFO order.
8289

83-
In `_SessionSubscribed`, any pipelined commands that were in-flight when SUBSCRIBE was sent are drained first (Redis guarantees in-order response delivery), then incoming responses are routed as pub/sub messages.
90+
In `_SessionSubscribed`, any pipelined commands that were in-flight when SUBSCRIBE was sent are drained first (Redis guarantees in-order response delivery), then incoming responses are routed as pub/sub messages. In RESP3 mode, pub/sub messages arrive as `RespPush` via `on_push`; in RESP2 mode they arrive as `RespArray` via `on_response`.
8491

8592
## Test Infrastructure
8693

8794
- Unit tests: `--exclude=integration/` — no external dependencies
8895
- Integration tests: `--only=integration/` — require a running Redis server
8996
- Test names prefixed with `integration/` for filtering
90-
- `_RedisTestConfiguration` reads environment variables for both plaintext and SSL Redis:
97+
- `_RedisTestConfiguration` reads environment variables for plaintext, SSL, and RESP2-only Redis:
9198
- `REDIS_HOST` / `REDIS_PORT` — plaintext (defaults to `127.0.0.2`/`6379` on Linux)
9299
- `REDIS_SSL_HOST` / `REDIS_SSL_PORT` — TLS (defaults to same host/`6380`)
100+
- `REDIS_RESP2_HOST` / `REDIS_RESP2_PORT` — RESP2-only Redis 5 (defaults to same host/`6381`)
93101

94102
### SSL-to-Plaintext Deadlock
95103

96104
Do not write tests that connect with SSL to a plaintext Redis server. The TLS ClientHello is binary data with no `\r\n`, so Redis's RESP parser buffers it waiting for a line terminator. Meanwhile the SSL client waits for a ServerHello. Neither side sends more data — both block indefinitely. To test the SSL constructor path, connect to a non-listening port instead (TCP connection refused is fast and deterministic).
97105

98106
### CI
99107

100-
Both `pr.yml` and `breakage-against-ponyc-latest.yml` use the `shared-docker-ci-standard-builder-with-libressl-4.2.0` image (for ssl support) and two Redis service containers: `redis` (plaintext) and `redis-ssl` (TLS via `ghcr.io/ponylang/redis-ci-redis-ssl:latest`). Integration tests receive `REDIS_HOST=redis`, `REDIS_PORT=6379`, `REDIS_SSL_HOST=redis-ssl`, and `REDIS_SSL_PORT=6379`. All make targets pass `ssl=libressl`.
108+
Both `pr.yml` and `breakage-against-ponyc-latest.yml` use the `shared-docker-ci-standard-builder-with-libressl-4.2.0` image (for ssl support) and three Redis service containers: `redis` (plaintext, Redis 7), `redis-ssl` (TLS via `ghcr.io/ponylang/redis-ci-redis-ssl:latest`), and `redis-resp2` (Redis 5, RESP2-only for HELLO fallback testing). Integration tests receive `REDIS_HOST=redis`, `REDIS_PORT=6379`, `REDIS_SSL_HOST=redis-ssl`, `REDIS_SSL_PORT=6379`, `REDIS_RESP2_HOST=redis-resp2`, and `REDIS_RESP2_PORT=6379`. All make targets pass `ssl=libressl`.
101109

102110
The `redis-ssl` CI image is built via `build-ci-image.yml` (manually triggered `workflow_dispatch`). Source: `.ci-dockerfiles/redis-ssl/Dockerfile`. Build locally with `.ci-dockerfiles/redis-ssl/build-and-push.bash`.
103111

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ start-redis:
5858
-p 6380:6379 \
5959
-d --entrypoint sh redis:7 \
6060
-c "cp /tls/redis.key.orig /tls/redis.key && chmod 600 /tls/redis.key && exec redis-server --tls-port 6379 --port 0 --tls-cert-file /tls/redis.crt --tls-key-file /tls/redis.key --tls-auth-clients no"
61+
@docker run --name redis-resp2 -p 6381:6379 -d redis:5
6162

6263
stop-redis:
63-
@docker stop redis redis-ssl && docker rm redis redis-ssl
64+
@docker stop redis redis-ssl redis-resp2 && docker rm redis redis-ssl redis-resp2
6465

6566
$(tests_binary): $(SOURCE_FILES) | $(BUILD_DIR)
6667
$(GET_DEPENDENCIES_WITH)

examples/resp3/main.pony

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use "cli"
2+
use lori = "lori"
3+
// in your code this `use` statement would be:
4+
// use "redis"
5+
use "../../redis"
6+
7+
actor Main
8+
new create(env: Env) =>
9+
let info = ServerInfo(env.vars)
10+
let auth = lori.TCPConnectAuth(env.root)
11+
Client(auth, info, env.out)
12+
13+
actor Client is (SessionStatusNotify & ResultReceiver)
14+
let _session: Session
15+
let _out: OutStream
16+
var _step: USize = 0
17+
18+
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
19+
_out = out
20+
_session = Session(
21+
ConnectInfo(auth, info.host, info.port where protocol' = Resp3),
22+
this)
23+
24+
be redis_session_ready(session: Session) =>
25+
_out.print("Connected with RESP3 protocol.")
26+
// HSET writes fields on a hash key.
27+
let cmd: Array[ByteSeq] val =
28+
["HSET"; "_resp3_example"; "name"; "Pony"; "version"; "0.60"]
29+
session.execute(cmd, this)
30+
31+
be redis_session_connection_failed(session: Session) =>
32+
_out.print("Failed to connect.")
33+
34+
be redis_response(session: Session, response: RespValue) =>
35+
_step = _step + 1
36+
if _step == 1 then
37+
// HSET response — integer count of fields added.
38+
_out.print("HSET done.")
39+
// HGETALL returns a map in RESP3 mode.
40+
let cmd: Array[ByteSeq] val = ["HGETALL"; "_resp3_example"]
41+
session.execute(cmd, this)
42+
elseif _step == 2 then
43+
// HGETALL response — RespMap in RESP3, RespArray in RESP2.
44+
match response
45+
| let m: RespMap =>
46+
_out.print("HGETALL returned a map with "
47+
+ m.pairs.size().string() + " pairs:")
48+
for (k, v) in m.pairs.values() do
49+
let ks = match k
50+
| let b: RespBulkString => String.from_array(b.value)
51+
else "?"
52+
end
53+
let vs = match v
54+
| let b: RespBulkString => String.from_array(b.value)
55+
else "?"
56+
end
57+
_out.print(" " + ks + " = " + vs)
58+
end
59+
| let a: RespArray =>
60+
// Fallback to RESP2 — server didn't support HELLO.
61+
_out.print("HGETALL returned an array (RESP2 fallback) with "
62+
+ a.values.size().string() + " elements.")
63+
else
64+
_out.print("Unexpected response type from HGETALL.")
65+
end
66+
// Clean up.
67+
let cmd: Array[ByteSeq] val = ["DEL"; "_resp3_example"]
68+
session.execute(cmd, this)
69+
else
70+
_out.print("Cleaned up. Done.")
71+
_session.close()
72+
end
73+
74+
be redis_command_failed(session: Session,
75+
command: Array[ByteSeq] val, failure: ClientError)
76+
=>
77+
_out.print("Command failed: " + failure.message())
78+
_session.close()
79+
80+
class val ServerInfo
81+
let host: String
82+
let port: String
83+
84+
new val create(vars: (Array[String] val | None)) =>
85+
let e = EnvVars(vars)
86+
host = try e("REDIS_HOST")? else
87+
ifdef linux then "127.0.0.2" else "localhost" end
88+
end
89+
port = try e("REDIS_PORT")? else "6379" end

0 commit comments

Comments
 (0)