Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/runtime/valkey_jsc/valkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,10 @@ impl ValkeyClient {

/// Handle Valkey protocol response
fn handle_response(&mut self, value: &mut RESPValue) -> JsTerminated<()> {
// Everything below (HELLO, SELECT, push routing, promise pairing)
// dispatches on the variant, so strip any attribute decoration first.
value.unwrap_attributes();

// Special handling for the initial HELLO response
if !self.flags.is_authenticated {
self.handle_hello_response(value)?;
Expand Down
14 changes: 14 additions & 0 deletions src/valkey/valkey_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ pub enum RESPValue {

// `deinit` deleted — all payloads are Box/Vec; Drop is automatic.

impl RESPValue {
/// Replace an attribute frame with the reply it decorates.
///
/// RESP3 attributes (`|N`) are out-of-band metadata that may prefix any
/// reply, pushes included, so callers that dispatch on the variant must
/// strip the decoration first.
pub fn unwrap_attributes(&mut self) {
while let RESPValue::Attribute(attribute) = self {
let decorated = core::mem::replace(attribute.value.as_mut(), RESPValue::Null);
*self = decorated;
}
}
}

impl fmt::Display for RESPValue {
fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Expand Down
47 changes: 2 additions & 45 deletions test/js/valkey/reliability/connection-failures.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RedisClient } from "bun";
import { describe, expect, mock, test } from "bun:test";
import net from "net";
import { DEFAULT_REDIS_OPTIONS, DEFAULT_REDIS_URL, delay, isEnabled } from "../test-utils";
import { DEFAULT_REDIS_OPTIONS, DEFAULT_REDIS_URL, delay, isEnabled, readRespCommands } from "../test-utils";

/**
* Test suite for connection failures, reconnection, and error handling
Expand Down Expand Up @@ -338,49 +338,6 @@ describe.skipIf(!isEnabled)("Valkey: Connection Failures", () => {
});

describe("Valkey: Auto-Reconnect In-Flight Commands", () => {
function readCommands(state: { buffer: Buffer }): string[][] {
const commands: string[][] = [];
while (true) {
const text = state.buffer.toString("latin1");
if (text[0] !== "*") break;
const headerEnd = text.indexOf("\r\n");
if (headerEnd === -1) break;
const argCount = parseInt(text.slice(1, headerEnd), 10);
if (!Number.isInteger(argCount) || argCount < 0) break;
let pos = headerEnd + 2;
const args: string[] = [];
let complete = true;
for (let i = 0; i < argCount; i++) {
if (text[pos] !== "$") {
complete = false;
break;
}
const lenEnd = text.indexOf("\r\n", pos);
if (lenEnd === -1) {
complete = false;
break;
}
const len = parseInt(text.slice(pos + 1, lenEnd), 10);
if (!Number.isInteger(len) || len < 0) {
complete = false;
break;
}
const dataStart = lenEnd + 2;
const dataEnd = dataStart + len;
if (text.length < dataEnd + 2) {
complete = false;
break;
}
args.push(text.slice(dataStart, dataEnd));
pos = dataEnd + 2;
}
if (!complete) break;
commands.push(args);
state.buffer = state.buffer.subarray(pos);
}
return commands;
}

test("rejects commands that were in flight when the connection dropped instead of pairing them with replies from the next connection", async () => {
const sockets: net.Socket[] = [];
let connections = 0;
Expand All @@ -393,7 +350,7 @@ describe("Valkey: Auto-Reconnect In-Flight Commands", () => {
const state = { buffer: Buffer.alloc(0) };
socket.on("data", chunk => {
state.buffer = Buffer.concat([state.buffer, chunk]);
for (const args of readCommands(state)) {
for (const args of readRespCommands(state)) {
const name = (args[0] ?? "").toUpperCase();
if (name === "HELLO") {
socket.write("+OK\r\n");
Expand Down
159 changes: 158 additions & 1 deletion test/js/valkey/reliability/protocol-handling.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { RedisClient } from "bun";
import { beforeEach, describe, expect, test } from "bun:test";
import { ConnectionType, createClient, ctx, isEnabled, testKey } from "../test-utils";
import net from "net";
import { ConnectionType, createClient, ctx, isEnabled, readRespCommands, testKey } from "../test-utils";

/**
* Test suite for RESP protocol handling, focusing on edge cases
Expand Down Expand Up @@ -399,3 +401,158 @@ describe.skipIf(!isEnabled)("Valkey: Protocol Handling", () => {
});
});
});

/**
* A RESP3 attribute (`|N`) is out-of-band metadata that may prefix *any* reply
* or push, so the outer frame type of a decorated reply says nothing about how
* the client should route it. These tests script the wire directly because no
* real server emits attributes on demand.
*/
describe("Valkey: RESP3 attributes", () => {
const CRLF = "\r\n";
const bulk = (value: string) => `$${Buffer.byteLength(value)}${CRLF}${value}${CRLF}`;
const pushFrame = (...frames: string[]) => `>${frames.length}${CRLF}${frames.join("")}`;
const HELLO_REPLY = `%3${CRLF}${bulk("server")}${bulk("redis")}${bulk("proto")}:3${CRLF}${bulk("version")}${bulk("7.4.0")}`;
// One-entry attribute map, the shape Redis/Valkey use for key-popularity hints.
const ATTRIBUTE = `|1${CRLF}${bulk("key-popularity")}:42${CRLF}`;

/**
* Run `body` against a client wired to a scripted RESP3 server. `reply` gets
* the upper-cased command name and its arguments and returns the raw bytes to
* answer with; returning `undefined` falls back to the HELLO handshake reply
* for HELLO and `+OK` for everything else.
*/
async function withScriptedServer<T>(
reply: (name: string, args: string[]) => string | undefined,
body: (client: RedisClient) => Promise<T>,
): Promise<T> {
const sockets = new Set<net.Socket>();
const server = net.createServer(socket => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
const state = { buffer: Buffer.alloc(0) };
socket.on("data", chunk => {
state.buffer = Buffer.concat([state.buffer, chunk]);
for (const args of readRespCommands(state)) {
const name = (args[0] ?? "").toUpperCase();
socket.write(reply(name, args.slice(1)) ?? (name === "HELLO" ? HELLO_REPLY : `+OK${CRLF}`));
}
});
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as net.AddressInfo;
const client = new RedisClient(`redis://127.0.0.1:${port}`, { autoReconnect: false });
try {
return await body(client);
} finally {
client.close();
for (const socket of sockets) socket.destroy();
server.close();
}
}

test("an attribute-decorated out-of-band push is not consumed as a command reply", async () => {
const values = ["A", "B", "C"];
const results = await withScriptedServer(
name => {
if (name !== "GET") return undefined;
// The push arrives before the first GET's reply, decorated by an attribute.
const push = values.length === 3 ? ATTRIBUTE + pushFrame(bulk("message"), bulk("chan"), bulk("hi")) : "";
return push + bulk(values.shift()!);
},
async client => {
await client.connect();
return [await client.get("a"), await client.get("b"), await client.get("c")];
},
);

// Without unwrapping the attribute the push is handed to `get("a")` and every
// later reply on the connection is shifted by one, forever.
expect(results).toEqual(["A", "B", "C"]);
});

test("an attribute-decorated subscribe confirmation enters subscriber mode", async () => {
const received = Promise.withResolvers<[string, string]>();
const subscribeResult = await withScriptedServer(
(name, args) => {
if (name !== "SUBSCRIBE") return undefined;
return (
ATTRIBUTE +
pushFrame(bulk("subscribe"), bulk(args[0]), `:1${CRLF}`) +
pushFrame(bulk("message"), bulk(args[0]), bulk("hi"))
);
},
async client => {
await client.connect();
const count = await client.subscribe("chan", (message, channel) => {
received.resolve([message, channel]);
});
// Without unwrapping, `subscribe()` resolves with the raw push object and
// the client never enters subscriber mode, so the listener never fires.
expect(count).toBe(1);
expect(await received.promise).toEqual(["hi", "chan"]);
return count;
},
);

expect(subscribeResult).toBe(1);
});

test("an attribute-decorated HELLO reply completes the handshake", async () => {
const value = await withScriptedServer(
name => {
if (name === "HELLO") return ATTRIBUTE + HELLO_REPLY;
if (name === "GET") return bulk("value");
return undefined;
},
async client => {
await client.connect();
return client.get("key");
},
);

expect(value).toBe("value");
});

test("an attribute-decorated error reply rejects the command", async () => {
await withScriptedServer(
name => (name === "GET" ? `${ATTRIBUTE}-ERR decorated failure${CRLF}` : undefined),
async client => {
await client.connect();
// Without unwrapping, the error is *resolved* as an Error object.
await expect(client.get("key")).rejects.toThrow("ERR decorated failure");
},
);
});

test("an attribute-decorated integer reply still coerces to boolean for EXISTS", async () => {
const exists = await withScriptedServer(
name => (name === "EXISTS" ? `${ATTRIBUTE}:1${CRLF}` : undefined),
async client => {
await client.connect();
return client.exists("key");
},
);

expect(exists).toBe(true);
});

test("attributes are transparent metadata around the value they decorate", async () => {
const [value, array] = await withScriptedServer(
name => {
if (name === "GET") return ATTRIBUTE + bulk("value");
// Attributes can also decorate an element nested inside an aggregate.
if (name === "LRANGE") return `*2${CRLF}${ATTRIBUTE}${bulk("a")}${bulk("b")}`;
return undefined;
},
async client => {
await client.connect();
return [await client.get("key"), await client.send("LRANGE", ["list", "0", "-1"])];
},
);

expect(value).toBe("value");
expect(array).toEqual(["a", "b"]);
});
});
47 changes: 47 additions & 0 deletions test/js/valkey/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,53 @@ if (!isEnabled) {
console.warn("Redis is not enabled, skipping tests");
}

/**
* Pull every complete `*N` RESP command frame out of `state.buffer`, leaving any
* trailing partial frame behind. For mock servers that script the wire directly.
*/
export function readRespCommands(state: { buffer: Buffer }): string[][] {
const commands: string[][] = [];
for (;;) {
const text = state.buffer.toString("latin1");
if (text[0] !== "*") break;
const headerEnd = text.indexOf("\r\n");
if (headerEnd === -1) break;
const argCount = parseInt(text.slice(1, headerEnd), 10);
if (!Number.isInteger(argCount) || argCount < 0) break;
let pos = headerEnd + 2;
const args: string[] = [];
let complete = true;
for (let i = 0; i < argCount; i++) {
if (text[pos] !== "$") {
complete = false;
break;
}
const lenEnd = text.indexOf("\r\n", pos);
if (lenEnd === -1) {
complete = false;
break;
}
const len = parseInt(text.slice(pos + 1, lenEnd), 10);
if (!Number.isInteger(len) || len < 0) {
complete = false;
break;
}
const dataStart = lenEnd + 2;
const dataEnd = dataStart + len;
if (text.length < dataEnd + 2) {
complete = false;
break;
}
args.push(text.slice(dataStart, dataEnd));
pos = dataEnd + 2;
}
if (!complete) break;
commands.push(args);
state.buffer = state.buffer.subarray(pos);
}
return commands;
}

/**
* Verify that a value is of a specific type
*/
Expand Down
Loading