Skip to content

redis: implement psubscribe/punsubscribe with listener routing - #35521

Open
robobun wants to merge 8 commits into
mainfrom
farm/e5422e81/redis-psubscribe-listener
Open

redis: implement psubscribe/punsubscribe with listener routing#35521
robobun wants to merge 8 commits into
mainfrom
farm/e5422e81/redis-psubscribe-listener

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

RedisClient.prototype.psubscribe() was a first-class prototype method but a silent no-op: it took no listener (arity 0), resolved with the raw RESP3 >psubscribe push frame as its return value, and every pmessage the server delivered afterward was dropped with no callback and no error. The client stayed connected, so pattern subscriptions appeared to work but lost 100% of their messages.

const c = new Bun.RedisClient(url);
await c.connect();
await c.psubscribe("evt:*");
// => { type: "psubscribe", data: ["evt:*", 1] }   (the raw push frame)
// pmessage frames for evt:1, evt:2, ... are silently discarded

send("PSUBSCRIBE", ...) on a client that already had a real subscribe() listener would additionally never settle and zombie the connection, because pmessage / psubscribe push kinds were unknown to the subscriber handler.

Fix

Give psubscribe(pattern, listener) and punsubscribe() the same listener routing that subscribe / unsubscribe already have:

  • SubscriptionPushMessage now recognizes pmessage / psubscribe / punsubscribe.
  • SubscriptionCtx keeps a second JSMap keyed by pattern so pattern and literal-channel listeners cannot collide, and punsubscribe() with no arguments clears only pattern listeners.
  • pmessage pushes are routed to the pattern listener with (message, channel) arguments, matching node-redis v4 pSubscribe.
  • subscribe / unsubscribe are refactored into shared helpers parametrized by SubscriptionKind, so psubscribe / punsubscribe share the same validation, error messages and rollback path.
  • Fixed the UNPSUBSCRIBE typo in the auto-pipelining disallow list.
await c.psubscribe("evt:*", (message, channel) => {
  console.log(channel, message);
});
// evt:1 payload1
// evt:2 payload2
// evt:3 payload3

psubscribe now has arity 2 and throws ERR_INVALID_ARG_TYPE when called without a listener.

Verification

  • RedisClient PSUBSCRIBE (mock server) block in valkey.test.ts (4 tests, self-contained RESP3 stub, no docker required): fails on current main, passes with this change.
  • 5 new real-Redis integration tests in the existing PUB/SUB block covering single/array patterns, punsubscribe(pattern), punsubscribe(pattern, listener), and mixed subscribe + psubscribe on one client.
  • Updated resp-nesting-depth.test.ts to pass a listener now that one is required.
  • Type declarations and docs added.

[review] gate passed · iteration 2 · 11 files touched

fails on main (without fix)
ASAN without fix: 5 failed, 920 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/reliability/resp-nesting-depth.test.ts test/js/valkey/valkey.test.ts
bun test v1.4.0 (b59249e26)

test/js/valkey/valkey.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Redis is not enabled, skipping tests
(skip) Valkey Redis Client (tls) > (unnamed)
(skip) Valkey Redis Client (tls) > Basic Operations > should keep process alive when connecting
(skip) Valkey Redis Client (tls) > Basic Operations > should set and get strings
(skip) Valkey Redis Client (tls) > Basic Operations > should test key existence
(skip) Valkey Redis Client (tls) > Basic Operations > should increment and decrement counters
(skip) Valkey Redis Client (tls) > Basic Operations > should increment by specified amount with INCRBY
(skip) Valkey Redis Client (tls) > Basic Operations > should increment by float amount with INCRBYFLOAT
(skip) Valkey Redis Client (tls) > Basic Operations > should decrement by
... (truncated)

release without fix: 920 skipped
bun test v1.4.0-canary.1 (b49edbb45)

test/js/valkey/valkey.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Redis is not enabled, skipping tests
(skip) Valkey Redis Client (tls) > (unnamed)
(skip) Valkey Redis Client (tls) > Basic Operations > should keep process alive when connecting
(skip) Valkey Redis Client (tls) > Basic Operations > should set and get strings
(skip) Valkey Redis Client (tls) > Basic Operations > should test key existence
(skip) Valkey Redis Client (tls) > Basic Operations > should increment and decrement counters
(skip) Valkey Redis Client (tls) > Basic Operations > should increment by specified amount with INCRBY
(skip) Valkey Redis Client (tls) > Basic Operations > should increment by float amount with INCRBYFLOAT
(skip) Valkey Redis Client (tls) > Basic Operations > should decrement by specified amount with DECRBY
(skip) Valkey Redis Client (tls) > Basic Operations > should rename a key with RENAME
(skip) Valkey Redis Client (tls) > Basic Operations > should rename a key with RENAME overwr
... (truncated)
passes on PR (with fix)
ASAN with fix: 920 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/valkey/reliability/resp-nesting-depth.test.ts test/js/valkey/valkey.test.ts
bun test v1.4.0 (b59249e26)

test/js/valkey/valkey.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Redis is not enabled, skipping tests
(skip) Valkey Redis Client (tls) > (unnamed)
(skip) Valkey Redis Client (tls) > Basic Operations > should keep process alive when connecting
(skip) Valkey Redis Client (tls) > Basic Operations > should set and get strings
(skip) Valkey Redis Client (tls) > Basic Operations > should test key existence
(skip) Valkey Redis Client (tls) > Basic Operations > should increment and decrement counters
(skip) Valkey Redis Client (tls) > Basic Operations > should increment by specified amount with INCRBY
(skip) Valkey Redis Client (tls) > Basic Operations > should increment by float amount with INCRBYFLOAT
(skip) Valkey Redis Client (tls) > Basic Operations > should decrement by
... (truncated)

release with fix: 920 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1444ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/29] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (31 fields)
Found 8 classes from /workspace/bun/src/runtime/api/html_rewriter.classes.ts
  - HTMLRewriter (3 fields)
  - TextChunk (7 fields)
  - DocType (5 fields
... (truncated)
diff hotspot
docs/runtime/redis.mdx                             |  26 +-
 packages/bun-types/redis.d.ts                      |  81 +++++-
 src/runtime/valkey_jsc/ValkeyCommand.rs            |   2 +-
 src/runtime/valkey_jsc/js_valkey.rs                |  88 +++++--
 src/runtime/valkey_jsc/js_valkey_functions.rs      | 189 +++++++++-----
 src/runtime/valkey_jsc/valkey.classes.ts           |   6 +-
 src/runtime/valkey_jsc/valkey.rs                   |  25 +-
 src/valkey/valkey_protocol.rs                      |  16 +-
 .../valkey/reliability/resp-nesting-depth.test.ts  |   4 +-
 test/js/valkey/test-utils.ts                       |   3 +
 test/js/valkey/valkey.test.ts                      | 279 +++++++++++++++++++++
 11 files changed, 614 insertions(+), 105 deletions(-)

gate history · 3 passed · 2 rejected · iteration 2

evidence per changed file
file                                                   reads  edits  tests
docs/runtime/redis.mdx                                     3      3      0
packages/bun-types/redis.d.ts                              2      2      0
src/runtime/valkey_jsc/ValkeyCommand.rs                    1      1      0
src/runtime/valkey_jsc/js_valkey.rs                        4     10      0
src/runtime/valkey_jsc/js_valkey_functions.rs              4      9      0
src/runtime/valkey_jsc/valkey.classes.ts                   1      2      0
src/runtime/valkey_jsc/valkey.rs                           2      4      0
src/valkey/valkey_protocol.rs                              3      4      0
test/js/valkey/reliability/resp-nesting-depth.test.ts      2      1      0
test/js/valkey/test-utils.ts                               1      1      0
test/js/valkey/valkey.test.ts                              4      6      0

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Pattern subscriptions

Layer / File(s) Summary
Public API and documentation
packages/bun-types/redis.d.ts, docs/runtime/redis.mdx, src/runtime/valkey_jsc/ValkeyCommand.rs
Adds psubscribe() and punsubscribe() declarations, documents pattern subscriptions, and corrects the disallowed command name to PUNSUBSCRIBE.
Protocol and callback routing
src/valkey/valkey_protocol.rs, src/runtime/valkey_jsc/js_valkey.rs, src/runtime/valkey_jsc/valkey.rs
Adds pattern push variants, separates channel and pattern callback maps, and routes pmessage frames through pattern listeners.
Subscribe and unsubscribe operations
src/runtime/valkey_jsc/js_valkey_functions.rs, src/runtime/valkey_jsc/valkey.classes.ts
Shares subscription logic across channel and pattern commands, including validation, listener registration, cleanup, command dispatch, and pattern callback state wiring.
Pattern subscription validation
test/js/valkey/valkey.test.ts, test/js/valkey/reliability/resp-nesting-depth.test.ts, test/js/valkey/test-utils.ts
Tests pattern delivery, listener-specific unsubscription, acknowledgements, subscriber-mode exit, normal commands after cleanup, and pattern cleanup handling.

Possibly related PRs

  • oven-sh/bun#34829: Updates related Valkey pub/sub protocol variants and pattern push routing.

Suggested reviewers: alii, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding listener routing for Redis pattern subscriptions.
Description check ✅ Passed It explains the change and verification clearly, though it uses Problem/Fix/Verification instead of the template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Mock-server tests in valkey.test.ts (RedisClient PSUBSCRIBE (mock server)) reproduce the silent-drop behavior without docker: they fail on main and pass with this change.

All automated review feedback addressed.

CI build 80153: the 80 test lanes that ran all pass; no valkey-related failures. The red is build infrastructure unrelated to this diff:

  • darwin-x64 / windows-aarch64 build-bun timed out waiting on sibling build-cpp jobs that never left the scheduled state (Rust compiled cleanly on both before the wait).
  • linux-aarch64, linux-aarch64-android, freebsd-aarch64 build-bun: step failed outside runner, flagged as pre-existing on main.
  • test-fs-read-stream-pos.js on Windows x64: marked flaky, unrelated to Redis.

Ready for review.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:19 AM PT - Jul 25th, 2026

@robobun, your commit b59249e has 1 failures in Build #80153 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35521

That installs a local version of the PR into your bun-35521 executable, so you can run:

bun-35521 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the two inline nits, I also checked: (1) the is_reply_kind change dropping the p prefix is safe — psubscribe/punsubscribe/pmessage now match via from_bytes directly, and ssubscribe/sunsubscribe still route through is_reply_kind, so promise-pair consumption in handle_response is unchanged; (2) the new patternCallbackMap slot is declared in values in valkey.classes.ts (so it's GC-visited) and cleared alongside subscriptionCallbackMap in SubscriptionCtx::close.

Extended reasoning...

This PR adds user-facing API (psubscribe/punsubscribe with listener routing), a second GC-rooted JSMap on the RedisClient wrapper, and refactors the existing subscribe/unsubscribe host functions into shared helpers parametrized by SubscriptionKind. The two findings are cosmetic nits (docs typo, two dead predicate helpers). I separately verified that the is_reply_kind narrowing preserves push-frame routing semantics for both pattern and sharded pub/sub kinds, and that the new cached-value slot is wired into codegen's values list and the context teardown path. Deferring because this is a non-trivial feature/API change, not because of correctness concerns.

Comment thread docs/runtime/redis.mdx
Comment thread src/valkey/valkey_protocol.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I also checked: (1) the is_reply_kind narrowing from p|ss only — the p* push kinds now hit from_bytes first so promise-pair routing in handle_response still consumes the pair for psubscribe/punsubscribe acks; (2) the new patternCallbackMap slot is declared in valkey.classes.ts values so it's GC-visited, and it's cleared alongside subscriptionCallbackMap in SubscriptionCtx::close().

Extended reasoning...

This run's only new finding is a cosmetic nit (hard-coded command name in two rare send-failure error strings). The two nits from the previous run — the docs UNPSUBSCRIBE typo and the dead is_subscribe/is_unsubscribe helpers — were addressed in 460fd14. The PR itself is a substantial feature (new user-facing API surface, second GC-rooted JSMap, refactor of existing subscribe/unsubscribe into shared helpers, protocol push-frame routing changes), so it should get a human look regardless; the note above records the two non-obvious correctness concerns I traced through and ruled out so a human reviewer doesn't have to re-derive them.

Comment thread src/runtime/valkey_jsc/js_valkey_functions.rs Outdated
robobun added 3 commits July 25, 2026 03:04
psubscribe() was a first-class prototype method but a silent no-op: it
took no listener, resolved with the raw RESP3 push frame as its value,
and every pmessage the server delivered afterward was dropped with no
callback and no error.

This gives psubscribe(pattern, listener) and punsubscribe() the same
treatment as subscribe/unsubscribe:

- SubscriptionPushMessage now recognizes pmessage/psubscribe/punsubscribe
- SubscriptionCtx keeps a second JSMap keyed by pattern so pattern and
  literal-channel listeners cannot collide and punsubscribe() only
  clears pattern listeners
- pmessage pushes are routed to the pattern listener with
  (message, channel) arguments, matching node-redis v4 pSubscribe
- subscribe/unsubscribe are refactored into shared helpers parametrized
  by SubscriptionKind so psubscribe/punsubscribe share the same
  validation, error messages and rollback path
- fixed UNPSUBSCRIBE typo in the auto-pipelining disallow list
@robobun
robobun force-pushed the farm/e5422e81/redis-psubscribe-listener branch from b323c67 to 730c58c Compare July 25, 2026 03:07
Comment thread test/js/valkey/valkey.test.ts Outdated
Comment thread packages/bun-types/redis.d.ts
Comment thread src/runtime/valkey_jsc/js_valkey.rs Outdated
Comment thread src/runtime/valkey_jsc/js_valkey.rs Outdated
Comment thread src/runtime/valkey_jsc/js_valkey.rs Outdated
Comment thread src/valkey/valkey_protocol.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/valkey_jsc/js_valkey_functions.rs (1)

1716-1725: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Rollback on subscribe failure wipes unrelated, already-active subscriptions.

On send() failure, clear_all_receive_handlers(kind, global) calls map.clear() — this empties every channel/pattern currently registered for that kind, not just the ones added by this call. If the client already has other working subscriptions of the same kind, a later failed subscribe()/psubscribe() (e.g. OOM in Entry::create) silently drops all of them client-side, even though Redis was never told to unsubscribe. This now affects both subscribe and psubscribe since the rollback is shared.

🛠️ Proposed fix: track and roll back only the channels/patterns added in this call
     fn do_subscribe(
         this: &Self,
         global: &JSGlobalObject,
         frame: &CallFrame,
         kind: SubscriptionKind,
         fn_name: &'static str,
         arg_name: &'static str,
         redis_command: &'static [u8],
         err_msg: &'static str,
     ) -> JsResult<JSValue> {
         let _guard = this.ref_scope();

         let [channel_or_many, handler_callback] = frame.arguments_as_array::<2>();
         let mut redis_channels: Vec<JSArgument> = Vec::with_capacity(1);
+        let mut registered_channels: Vec<JSValue> = Vec::with_capacity(1);

         ...
                 redis_channels.push(channel);
                 this._subscription_ctx.get().upsert_receive_handler(
                     kind,
                     global,
                     channel_arg,
                     handler_callback,
                 )?;
+                registered_channels.push(channel_arg);
         ...
             redis_channels.push(channel);
             this._subscription_ctx.get().upsert_receive_handler(
                 kind,
                 global,
                 channel_or_many,
                 handler_callback,
             )?;
+            registered_channels.push(channel_or_many);
         ...
         let promise = match this.send(global, frame.this(), &command) {
             Ok(p) => p,
             Err(err) => {
-                // If we catch an error, we need to clean up any handlers we may have added and fall out of subscription mode
-                this._subscription_ctx
-                    .get()
-                    .clear_all_receive_handlers(kind, global)?;
+                // Only remove the handlers we just added in this call — other
+                // active subscriptions for this `kind` must be left intact.
+                for ch in &registered_channels {
+                    let _ = this._subscription_ctx.get().remove_receive_handler(
+                        kind,
+                        global,
+                        *ch,
+                        handler_callback,
+                    );
+                }
                 return send_err_to_js(global, err_msg, &err);
             }
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/valkey_jsc/js_valkey_functions.rs` around lines 1716 - 1725,
Update the shared subscribe/psubscribe failure rollback around send and the
subscription context handlers so it removes only channels or patterns registered
by the current call, rather than invoking clear_all_receive_handlers, which
clears the entire kind map. Track the entries added during this call and roll
back those entries on send failure while preserving existing active
subscriptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/valkey.test.ts`:
- Around line 7137-7151: Remove the : any annotations from all three RedisClient
declarations in the psubscribe tests, including the sites surrounding
“psubscribe without a listener throws” and “psubscribe delivers pmessage pushes
to its listener,” so they use inferred RedisClient types and validate the new
psubscribe/punsubscribe declarations.

---

Outside diff comments:
In `@src/runtime/valkey_jsc/js_valkey_functions.rs`:
- Around line 1716-1725: Update the shared subscribe/psubscribe failure rollback
around send and the subscription context handlers so it removes only channels or
patterns registered by the current call, rather than invoking
clear_all_receive_handlers, which clears the entire kind map. Track the entries
added during this call and roll back those entries on send failure while
preserving existing active subscriptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d28b423a-fc41-4ad0-be1e-7e7d450456b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1960936 and 9f46554.

📒 Files selected for processing (11)
  • docs/runtime/redis.mdx
  • packages/bun-types/redis.d.ts
  • src/runtime/valkey_jsc/ValkeyCommand.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/runtime/valkey_jsc/js_valkey_functions.rs
  • src/runtime/valkey_jsc/valkey.classes.ts
  • src/runtime/valkey_jsc/valkey.rs
  • src/valkey/valkey_protocol.rs
  • test/js/valkey/reliability/resp-nesting-depth.test.ts
  • test/js/valkey/test-utils.ts
  • test/js/valkey/valkey.test.ts

Comment thread test/js/valkey/valkey.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found, but this adds new public API surface (psubscribe(pattern, listener) / punsubscribe() with the node-redis-v4 (message, channel) callback shape) and changes psubscribe's arity/behavior, so worth a human sign-off on the API.

What was reviewed:

  • is_reply_kind narrowing to s-prefix only is safe — p* kinds now hit from_bytes first, so the None arm in handle_response never sees them.
  • patternCallbackMap is added to the values: array in valkey.classes.ts, so it's GC-visited alongside subscriptionCallbackMap; SubscriptionCtx::close clears both.
  • channels_subscribed_to_count sums both maps, so remove_subscription / has_subscriptions correctly keep subscriber mode when only pattern subs remain (covered by the mixed subscribe+psubscribe test).
  • The send-failure rollback now removes only this call's listeners via remove_receive_handler, not clear_all_receive_handlers.
Extended reasoning...

Overview

This PR promotes psubscribe / punsubscribe from arity-0 cmd_strings_varargs! no-ops to first-class subscriber-mode methods with listener routing, mirroring the existing subscribe / unsubscribe implementation. It touches 11 files: the RESP protocol enum (valkey_protocol.rs) gains Pmessage/Psubscribe/Punsubscribe variants and an is_message() predicate; SubscriptionCtx in js_valkey.rs grows a second JSMap keyed by pattern with a SubscriptionKind enum threaded through every accessor; js_valkey_functions.rs refactors subscribe/unsubscribe into shared do_subscribe/do_unsubscribe helpers parametrized by kind/fn_name/arg_name/redis_command/err_msg; valkey.rs routes pmessage pushes and pairs Psubscribe/Punsubscribe with the existing subscribe/unsubscribe arms; valkey.classes.ts adds patternCallbackMap to the visited values and sets length: 2 on both subscribe methods. Docs, .d.ts, and ~280 lines of tests (mock-server + real-Redis) round it out. Also fixes the UNPSUBSCRIBE typo in AUTO_PIPELINE_DISALLOWED_COMMANDS.

Security risks

None identified. The change is confined to pub/sub listener bookkeeping and RESP push-frame routing on an already-authenticated connection. No new parsing of untrusted lengths (the pmessage frame's element indices are bounds-checked via value.len() <= message_idx before indexing). The pattern string is passed through to Redis verbatim as it already was.

Level of scrutiny

Medium-high. This is user-facing API design (callback signature, arity, error messages, return value of the promise) plus a behavioral change to an existing prototype method — psubscribe() now throws ERR_INVALID_ARG_TYPE without a listener where it previously returned the raw push frame. The refactor of subscribe/unsubscribe into shared helpers is behavior-preserving as far as I can trace (all error strings parametrized, rollback narrowed to per-call listeners which is strictly better than the old clear_all_receive_handlers), but it's ~170 lines of moved/reshaped code in a path with re-entrant JS callbacks and refcounted lifetime management. The second cached JSMap is correctly declared in values: so codegen emits the visitor, and SubscriptionCtx::init/close handle both slots symmetrically.

Other factors

Four rounds of prior review feedback (docs typo mirror, dead is_subscribe/is_unsubscribe helpers, hard-coded error strings in shared helpers, unhandled-rejection in the mock punsubscribe test, stale subscribe() JSDoc, comment-cop flags, : any in tests) have all been addressed and the threads resolved. Test coverage is solid: 4 mock-server tests exercise the listener path without docker (so they run in every CI lane), plus 5 real-Redis integration tests covering single/array patterns, targeted punsubscribe(pattern) and punsubscribe(pattern, listener), and mixed channel+pattern subscriptions on one client. The resp-nesting-depth.test.ts update confirms the arity change is intentional.

Deferring because new public API shape and a breaking arity change on an existing method are decisions a maintainer should confirm, not because anything looks wrong.

alii pushed a commit that referenced this pull request Aug 16, 2026
…s tables (#39271)

### Problem
- `class RedisClient` in `packages/bun-types/redis.d.ts` is a
hand-written copy of the `proto` table in
`src/runtime/valkey_jsc/valkey.classes.ts`, and nothing compares the
two. A command added to the table and not to the d.ts works at runtime
and fails to type-check (`Property 'pubsub' does not exist on type
'RedisClient'`).
- On main, 5 of the 167 table entries are undeclared: `psubscribe`,
`pubsub`, `punsubscribe`, `script`, `select`. All five have been
registered since the commit that introduced `Bun.redis` (ec87a27,
#18812) and stayed undeclared through the 14 redis.d.ts commits since,
including the 614-line batch in #23116, so the gap is systematic rather
than a one-off.
- Each of the five is already being declared by an open PR: `pubsub` and
`select` by #39208, `script` by #29339, `psubscribe`/`punsubscribe` by
#35521 (which also adds the listener routing they are missing; today
`psubscribe()` drops every pmessage). This PR adds the check, not the
declarations.

### Fix
- `test/internal/source-lints/redis-client-types.test.ts` imports
`valkey.classes.ts` (the module the codegen reads;
`class-definitions.ts` has no dependencies), collects the members the
codegen installs (`proto` entries by name, `klass` entries as `static
name`, `constructor` when `construct` is set, skipping
`internal`/`privateSymbol`/`publicSymbol` entries, which the codegen
does not install under an identifier), parses the member names declared
in the `class RedisClient` body of `redis.d.ts` (`[Symbol.x]` maps onto
the table's `@@x` spelling), and requires the two sets to be equal. Each
direction is its own test, so a table entry without a declaration and a
declaration without a table entry are both reported by name.
- The five names missing today sit in a `pendingDeclarations` table
keyed by the PR that declares each. A fourth test fails as soon as a
listed name is declared or unregistered, so the entry gets deleted when
its PR lands (whichever of this PR and #39208/#29339/#35521 lands second
trips it on rebase, and the message says which entry to delete). A name
that is neither declared nor listed fails the lint outright.
- Declaration lines the parser does not recognize are reported as
failures rather than skipped, so a new d.ts shape cannot silently hide a
member from the comparison.
- `.github/workflows/source-lints.yml` gains `src/**/*.classes.ts`,
`src/codegen/class-definitions.ts` and `packages/bun-types/redis.d.ts`
as triggers: the workflow is path-filtered and those are the files this
lint reads, so without them the edits it guards would not run it. The
directory README now states that rule; a comment on the `proto` table
points at the lint.
- Scope is RedisClient only. A checker for every `*.classes.ts` needs a
class-to-declaration mapping and inheritance handling and is a separate
project.
- Verified:
- `bun test test/internal/source-lints/redis-client-types.test.ts`
passes on this branch (4 tests); the whole directory is 170 green.
- With `pendingDeclarations` emptied, the lint fails against main's
files naming exactly `psubscribe`, `pubsub`, `punsubscribe`, `script`,
`select` (output below).
- Simulated the eight drift shapes in the details block (new table
entry, pending name declared, phantom declaration, pending name
unregistered, unparseable member, `@@asyncDispose` + a `klass` entry
with and without declarations, `internal`/`privateSymbol` entries); each
fails the intended test or passes as intended.
- No runtime code changes: the `valkey.classes.ts` hunk is a comment and
produces identical codegen output. This PR has no src/packages diff for
a fail-before run to strip; the fail-before evidence is the
emptied-pending-table run above.

### Background
- `*.classes.ts` files are the input of
`src/codegen/generate-classes.ts`. `define({ proto, klass, construct })`
describes a native class: `proto` entries become properties of the
prototype (`fn` methods, `getter`/`setter` accessors), `klass` entries
become statics, and `construct: true` makes it newable. Entries with
`internal`, `privateSymbol` or `publicSymbol` are installed under
private names or `Symbol.for()` symbols (or not at all), and keys
spelled `@@x` are installed under the well-known symbol `Symbol.x`.
- `packages/bun-types` is the published `@types/bun` surface; it is
hand-written, not generated from the class definitions, which is why it
can drift.
- `test/internal/source-lints/` holds tests that only read the source
tree; `.buildkite/ci.mjs` excludes the directory from the binary lanes
and `source-lints.yml` runs it against a released bun on a bare
checkout, so tests there may only import built-ins and relative paths.

<details>
<summary>Lint output against main's files with the pending table
emptied, and the simulated drift shapes</summary>

```
(pass) every member of class RedisClient in packages/bun-types/redis.d.ts has a shape this lint can read
(fail) packages/bun-types/redis.d.ts declares every RedisClient member src/runtime/valkey_jsc/valkey.classes.ts installs
    - []
    + [
    +   "psubscribe",
    +   "pubsub",
    +   "punsubscribe",
    +   "script",
    +   "select",
    + ]
(pass) packages/bun-types/redis.d.ts declares no RedisClient member src/runtime/valkey_jsc/valkey.classes.ts does not install
(pass) pendingDeclarations lists only members that are still registered and still undeclared
```

```
### proto gains waitaof, d.ts untouched
    +   "waitaof",
    (fail) packages/bun-types/redis.d.ts declares every RedisClient member src/runtime/valkey_jsc/valkey.classes.ts installs
### d.ts declares select while it is still pending
    +   "select (#39208) is declared in packages/bun-types/redis.d.ts now; delete its entry",
    (fail) pendingDeclarations lists only members that are still registered and still undeclared
### d.ts declares flushall, which is not registered
    +   "flushall",
    (fail) packages/bun-types/redis.d.ts declares no RedisClient member src/runtime/valkey_jsc/valkey.classes.ts does not install
### proto drops script while it is still pending
    +   "script (#29339) is no longer registered in src/runtime/valkey_jsc/valkey.classes.ts",
    (fail) pendingDeclarations lists only members that are still registered and still undeclared
### d.ts gains `private brand: never;`
    +   "private brand: never;",
    (fail) every member of class RedisClient in packages/bun-types/redis.d.ts has a shape this lint can read
### proto gains "@@asyncDispose" and a klass entry, d.ts declares [Symbol.asyncDispose]() and the static
    4 pass
### same, with nothing declared
    +   "@@asyncDispose",
    +   "static parseURL",
    (fail) packages/bun-types/redis.d.ts declares every RedisClient member src/runtime/valkey_jsc/valkey.classes.ts installs
### proto gains an `internal: true` entry and a `privateSymbol` entry, d.ts untouched
    4 pass
```

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants