Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
83aa81ddf6 [autofix.ci] apply automated fixes 2025-09-08 08:26:04 +00:00
Claude Bot
5957a44e57 fix: Add Redis 5 compatibility with RESP2 fallback
Fixes #22483 - Redis client now works with Redis 5 servers that don't support the HELLO command.

## Problem
Redis 5 doesn't support the HELLO command (introduced in Redis 6 for RESP3 protocol negotiation), causing connection failures with "ERR unknown command HELLO".

## Solution
Implemented a fallback mechanism that:
1. Detects when HELLO returns "unknown command" error
2. Falls back to RESP2 protocol without HELLO
3. Uses AUTH command directly for authentication
4. Maintains full compatibility with Redis 6+ (still uses RESP3)

## Changes
- Added `is_using_resp2_fallback` flag to track RESP2 mode
- Modified `handleHelloResponse` to detect and handle HELLO errors
- Added `authenticateWithoutHello` function for RESP2 authentication
- Updated `handleResponse` to process AUTH responses in RESP2 mode
- Reset fallback flag on reconnection

## Testing
- Added comprehensive test suite for Redis 5 compatibility
- Tests all major Redis operations (SET/GET, Hashes, Lists, Sets, etc.)
- Includes Docker setup for testing with actual Redis 5 and Redis 7

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 08:23:14 +00:00
4 changed files with 415 additions and 3 deletions

View File

@@ -18,6 +18,7 @@ pub const ConnectionFlags = struct {
is_reconnecting: bool = false,
auto_pipelining: bool = true,
finalized: bool = false,
is_using_resp2_fallback: bool = false, // For Redis 5 compatibility
};
/// Valkey connection status
@@ -466,6 +467,7 @@ pub const ValkeyClient = struct {
this.flags.is_reconnecting = true;
this.flags.is_authenticated = false;
this.flags.is_selecting_db_internal = false;
this.flags.is_using_resp2_fallback = false;
// Signal reconnect timer should be started
this.onValkeyReconnect();
@@ -618,6 +620,16 @@ pub const ValkeyClient = struct {
switch (value.*) {
.Error => |err| {
// Check if Redis 5 (doesn't support HELLO command)
if (std.mem.indexOf(u8, err, "unknown command") != null or
std.mem.indexOf(u8, err, "Unknown command") != null or
std.mem.indexOf(u8, err, "HELLO") != null)
{
debug("HELLO command not supported, falling back to RESP2", .{});
this.flags.is_using_resp2_fallback = true;
this.authenticateWithoutHello();
return;
}
this.fail(err, protocol.RedisError.AuthenticationFailed);
return;
},
@@ -671,11 +683,36 @@ pub const ValkeyClient = struct {
/// Handle Valkey protocol response
fn handleResponse(this: *ValkeyClient, value: *protocol.RESPValue) !void {
debug("onData() {any}", .{value.*});
// Special handling for the initial HELLO response
// Special handling for the initial HELLO response or AUTH response in RESP2 mode
if (!this.flags.is_authenticated) {
this.handleHelloResponse(value);
if (this.flags.is_using_resp2_fallback and this.password.len > 0) {
// Handle AUTH response in RESP2 fallback mode
switch (value.*) {
.SimpleString => |str| {
if (std.mem.eql(u8, str, "OK")) {
this.status = .connected;
this.flags.is_authenticated = true;
this.onValkeyConnect(value);
return;
}
this.fail("Authentication failed", protocol.RedisError.AuthenticationFailed);
return;
},
.Error => |err| {
this.fail(err, protocol.RedisError.AuthenticationFailed);
return;
},
else => {
this.fail("Unexpected AUTH response", protocol.RedisError.AuthenticationFailed);
return;
},
}
} else {
// Handle HELLO response
this.handleHelloResponse(value);
}
// We've handled the HELLO response without consuming anything from the command queue
// We've handled the HELLO/AUTH response without consuming anything from the command queue
return;
}
@@ -791,6 +828,59 @@ pub const ValkeyClient = struct {
}
}
/// Authenticate without HELLO for Redis 5 compatibility (RESP2)
fn authenticateWithoutHello(this: *ValkeyClient) void {
debug("Authenticating with RESP2 (Redis 5 compatibility mode)", .{});
// Send AUTH command if credentials are provided
if (this.password.len > 0) {
var auth_cmd: Command = undefined;
if (this.username.len > 0) {
// AUTH username password
var auth_args = [_][]const u8{ this.username, this.password };
auth_cmd = Command{
.command = "AUTH",
.args = .{ .raw = &auth_args },
};
} else {
// AUTH password (for Redis < 6 compatibility)
var auth_args = [_][]const u8{this.password};
auth_cmd = Command{
.command = "AUTH",
.args = .{ .raw = &auth_args },
};
}
auth_cmd.write(this.writer()) catch |err| {
this.fail("Failed to write AUTH command", err);
return;
};
} else {
// No authentication needed, mark as authenticated
this.status = .connected;
this.flags.is_authenticated = true;
// Need to call onValkeyConnect with a dummy value
var dummy_value = protocol.RESPValue{ .SimpleString = "OK" };
this.onValkeyConnect(&dummy_value);
}
// If using a specific database, send SELECT command
if (this.database > 0) {
var int_buf: [64]u8 = undefined;
const db_str = std.fmt.bufPrintZ(&int_buf, "{d}", .{this.database}) catch unreachable;
var select_cmd = Command{
.command = "SELECT",
.args = .{ .raw = &[_][]const u8{db_str} },
};
select_cmd.write(this.writer()) catch |err| {
this.fail("Failed to write SELECT command", err);
return;
};
this.flags.is_selecting_db_internal = true;
}
}
/// Handle socket open event
pub fn onOpen(this: *ValkeyClient, socket: uws.AnySocket) void {
this.socket = socket;

View File

@@ -0,0 +1,20 @@
# Dockerfile for Redis 5 to test backward compatibility
FROM redis:5-alpine
# Set user to root
USER root
# Install bash for initialization scripts
RUN apk add --no-cache bash
# Create directories
RUN mkdir -p /etc/redis
# Copy configuration files
COPY redis5.conf /etc/redis/
# Expose port
EXPOSE 6379
# Use Redis 5 with custom config
CMD ["redis-server", "/etc/redis/redis5.conf"]

View File

@@ -0,0 +1,16 @@
# Redis 5 configuration for testing
port 6379
bind 0.0.0.0
protected-mode no
timeout 0
tcp-keepalive 300
daemonize no
supervised no
loglevel notice
databases 16
save ""
stop-writes-on-bgsave-error no
rdbcompression yes
rdbchecksum yes
dbfilename dump.rdb
dir /data

View File

@@ -0,0 +1,286 @@
import { RedisClient } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, randomPort } from "harness";
import path from "path";
// Test for issue #22483 - Redis 5 compatibility
// This test ensures Bun's Redis client works with Redis 5 which doesn't support HELLO command
const dockerCLI = Bun.which("docker") as string;
const isEnabled =
!!dockerCLI &&
(() => {
try {
const info = Bun.spawnSync({
cmd: [dockerCLI, "info"],
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
timeout: 5_000,
});
return info.exitCode === 0 && !info.signalCode;
} catch {
return false;
}
})();
describe.skipIf(!isEnabled)("Redis 5 Compatibility (#22483)", () => {
let redis5Port: number;
let redis7Port: number;
let redis5Container: string;
let redis7Container: string;
let redis5Client: RedisClient;
let redis7Client: RedisClient;
beforeAll(async () => {
// Build Redis 5 Docker image
console.log("Building Redis 5 Docker image...");
const dockerfilePath = path.join(import.meta.dir, "docker-redis5", "Dockerfile");
await Bun.spawn([dockerCLI, "build", "--rm", "-f", dockerfilePath, "-t", "bun-redis5-test", "."], {
cwd: path.join(import.meta.dir, "docker-redis5"),
stdio: ["inherit", "inherit", "inherit"],
}).exited;
// Start Redis 5 container
redis5Port = randomPort();
redis5Container = `redis5-test-${Date.now()}`;
console.log(`Starting Redis 5 container on port ${redis5Port}...`);
const start5 = Bun.spawn({
cmd: [dockerCLI, "run", "-d", "--name", redis5Container, "-p", `${redis5Port}:6379`, "bun-redis5-test"],
stdout: "pipe",
stderr: "pipe",
});
const container5Id = await new Response(start5.stdout).text();
const exit5 = await start5.exited;
if (exit5 !== 0) {
const stderr = await new Response(start5.stderr).text();
throw new Error(`Failed to start Redis 5: ${stderr}`);
}
console.log(`Redis 5 container started: ${container5Id.slice(0, 12)}`);
// Start Redis 7 container for comparison
redis7Port = randomPort();
redis7Container = `redis7-test-${Date.now()}`;
console.log(`Starting Redis 7 container on port ${redis7Port}...`);
const start7 = Bun.spawn({
cmd: [dockerCLI, "run", "-d", "--name", redis7Container, "-p", `${redis7Port}:6379`, "redis:7-alpine"],
stdout: "pipe",
stderr: "pipe",
});
const container7Id = await new Response(start7.stdout).text();
const exit7 = await start7.exited;
if (exit7 !== 0) {
const stderr = await new Response(start7.stderr).text();
throw new Error(`Failed to start Redis 7: ${stderr}`);
}
console.log(`Redis 7 container started: ${container7Id.slice(0, 12)}`);
// Wait for containers to be ready
await new Promise(resolve => setTimeout(resolve, 3000));
// Verify Redis 5 version
const version5Check = Bun.spawn({
cmd: [dockerCLI, "exec", redis5Container, "redis-cli", "info", "server"],
stdout: "pipe",
});
const version5Info = await new Response(version5Check.stdout).text();
const version5Match = version5Info.match(/redis_version:(\d+)/);
if (version5Match) {
console.log(`Redis 5 version confirmed: ${version5Match[0]}`);
}
// Connect clients
redis5Client = new RedisClient(`redis://localhost:${redis5Port}`);
redis7Client = new RedisClient(`redis://localhost:${redis7Port}`);
});
afterAll(async () => {
// Close clients
if (redis5Client) await redis5Client.close();
if (redis7Client) await redis7Client.close();
// Clean up containers
if (redis5Container) {
await Bun.spawn([dockerCLI, "rm", "-f", redis5Container]).exited;
}
if (redis7Container) {
await Bun.spawn([dockerCLI, "rm", "-f", redis7Container]).exited;
}
});
test("Redis 5 - should work with RESP2 fallback (no HELLO support)", async () => {
// This would fail before the fix with "ERR unknown command `HELLO`"
// After the fix, it should fall back to RESP2 and work
const key = `test-redis5-${Date.now()}`;
const value = "Hello from Redis 5!";
// Basic SET operation
const setResult = await redis5Client.set(key, value);
expect(setResult).toBe("OK");
// Basic GET operation
const getValue = await redis5Client.get(key);
expect(getValue).toBe(value);
// EXISTS operation
const exists = await redis5Client.exists(key);
expect(exists).toBe(true);
// DEL operation
const delResult = await redis5Client.del(key);
expect(delResult).toBe(1);
// Verify deletion
const existsAfterDel = await redis5Client.exists(key);
expect(existsAfterDel).toBe(false);
});
test("Redis 7 - should work with RESP3 (HELLO supported)", async () => {
// This should work normally with RESP3 protocol
const key = `test-redis7-${Date.now()}`;
const value = "Hello from Redis 7 with RESP3!";
// Basic SET operation
const setResult = await redis7Client.set(key, value);
expect(setResult).toBe("OK");
// Basic GET operation
const getValue = await redis7Client.get(key);
expect(getValue).toBe(value);
// EXISTS operation
const exists = await redis7Client.exists(key);
expect(exists).toBe(true);
// DEL operation
const delResult = await redis7Client.del(key);
expect(delResult).toBe(1);
// Verify deletion
const existsAfterDel = await redis7Client.exists(key);
expect(existsAfterDel).toBe(false);
});
test("Redis 5 - complex operations should work", async () => {
// Test more complex operations to ensure full compatibility
// Hash operations
const hashKey = `hash-test-${Date.now()}`;
await redis5Client.hmset(hashKey, { field1: "value1", field2: "value2" });
const hashValues = await redis5Client.hmget(hashKey, ["field1", "field2"]);
expect(hashValues).toEqual(["value1", "value2"]);
// List operations
const listKey = `list-test-${Date.now()}`;
await redis5Client.send("RPUSH", [listKey, "item1", "item2", "item3"]);
const listLen = await redis5Client.send("LLEN", [listKey]);
expect(listLen).toBe(3);
// Set operations
const setKey = `set-test-${Date.now()}`;
await redis5Client.sadd(setKey, "member1");
await redis5Client.sadd(setKey, "member2");
const isMember = await redis5Client.sismember(setKey, "member1");
expect(isMember).toBe(true);
// Counter operations
const counterKey = `counter-test-${Date.now()}`;
await redis5Client.set(counterKey, "0");
const incrResult = await redis5Client.incr(counterKey);
expect(incrResult).toBe(1);
const decrResult = await redis5Client.decr(counterKey);
expect(decrResult).toBe(0);
// Clean up
await redis5Client.del(hashKey);
await redis5Client.del(listKey);
await redis5Client.del(setKey);
await redis5Client.del(counterKey);
});
test("Redis 5 with authentication should work", async () => {
// Test with password authentication (Redis 5 style)
const authContainer = `redis5-auth-test-${Date.now()}`;
const authPort = randomPort();
// Start Redis 5 with password
const startAuth = Bun.spawn({
cmd: [
dockerCLI,
"run",
"-d",
"--name",
authContainer,
"-p",
`${authPort}:6379`,
"redis:5-alpine",
"redis-server",
"--requirepass",
"testpass123",
],
stdout: "pipe",
stderr: "pipe",
});
const containerAuthId = await new Response(startAuth.stdout).text();
const exitAuth = await startAuth.exited;
if (exitAuth !== 0) {
const stderr = await new Response(startAuth.stderr).text();
throw new Error(`Failed to start Redis 5 with auth: ${stderr}`);
}
// Wait for container to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
try {
// Connect with password
const authClient = new RedisClient(`redis://:testpass123@localhost:${authPort}`);
// Should work with authentication
const authKey = `auth-test-${Date.now()}`;
const setResult = await authClient.set(authKey, "authenticated");
expect(setResult).toBe("OK");
const getValue = await authClient.get(authKey);
expect(getValue).toBe("authenticated");
await authClient.close();
} finally {
// Clean up auth container
await Bun.spawn([dockerCLI, "rm", "-f", authContainer]).exited;
}
});
test("verifies RESP2 fallback is actually being used for Redis 5", async () => {
// This test documents what happens internally
// Redis 5: HELLO fails -> fallback to RESP2 -> works
// Redis 7: HELLO succeeds -> uses RESP3 -> works
const testKey = `protocol-test-${Date.now()}`;
// Both should work, but internally using different protocols
await redis5Client.set(testKey, "resp2");
await redis7Client.set(testKey, "resp3");
const value5 = await redis5Client.get(testKey);
const value7 = await redis7Client.get(testKey);
expect(value5).toBe("resp2");
expect(value7).toBe("resp3");
// Clean up
await redis5Client.del(testKey);
await redis7Client.del(testKey);
});
});