-
-
Notifications
You must be signed in to change notification settings - Fork 638
Refactor redisReceiver to eliminate global state and prevent memory leaks #1894
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…eaks This commit completely refactors the redisReceiver module to eliminate all module-level global state and move to request-scoped encapsulation. Changes: - Remove global sharedRedisClient, activeListeners, and pendingPromises - Create private Redis client per listenToRequestData() call - Move pendingPromises to per-listener scope for complete request isolation - Remove listener caching mechanism (no reuse via activeListeners) - Fix race condition bug in connection logic using connectionPromise tracking - Always close Redis client in close() method (no conditional logic) - Use delete instead of setting to undefined for proper memory cleanup Benefits: - Complete request isolation - no shared state between concurrent requests - Eliminates memory leaks from accumulating undefined map entries - Prevents race condition where multiple concurrent connection attempts fail - Simpler code without global state management complexity - Better resource management with guaranteed cleanup External API unchanged - no breaking changes to consumers. Fixes #1893 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
WalkthroughReplace module-level Redis globals with per-listener isolated Redis clients and state. Change Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Consumer
participant Listener as listenToRequestData
participant Redis as Redis Client
Note over Listener: Per-listener isolated state & client
rect rgb(240,248,255)
Consumer->>Listener: call listenToRequestData(requestId)
Listener->>Listener: create local maps, flags, streamKey
end
rect rgb(240,255,240)
Consumer->>Listener: call getValue(key)
Listener->>Listener: check local map -> if missing, create promise
Listener->>Listener: ensureConnected() (serialized)
Listener->>Redis: XREAD BLOCK ...
Redis-->>Listener: stream message(s)
Listener->>Listener: update local map, resolve promises
Listener-->>Consumer: return value
end
rect rgb(255,245,235)
Consumer->>Listener: call destroy()
Listener->>Redis: client.quit()
Listener->>Listener: reject & clear pending, mark destroyed
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (5 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (4)
🧰 Additional context used📓 Path-based instructions (2)**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}📄 CodeRabbit inference engine (CLAUDE.md)
Files:
**/*.{js,jsx,ts,tsx}📄 CodeRabbit inference engine (CLAUDE.md)
Files:
🧠 Learnings (1)📚 Learning: 2024-12-12T13:07:09.929ZApplied to files:
🧬 Code graph analysis (2)react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (2)
react_on_rails_pro/packages/node-renderer/tests/redisClient.test.ts (1)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
🔇 Additional comments (9)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Code Review: Refactor redisReceiver to Eliminate Global StateOverall Assessment: ✅ Excellent RefactoringThis is a high-quality refactoring that addresses critical architectural issues. The changes demonstrate strong understanding of concurrency, resource management, and clean code principles. Strengths1. Architecture & Design ⭐⭐⭐⭐⭐
2. Bug Fixes ⭐⭐⭐⭐⭐
3. Code Quality ⭐⭐⭐⭐
Suggestions for Improvement1. Error Handling Enhancement (Minor)The async function ensureConnected(): Promise<RedisClientType> {
if (isClientConnected) {
return redisClient;
}
if (!connectionPromise) {
connectionPromise = redisClient.connect()
.then(() => {
isClientConnected = true;
connectionPromise = null;
})
.catch((error) => {
connectionPromise = null; // Reset so next call can retry
throw error;
});
}
await connectionPromise;
return redisClient;
}Why: Currently, if connection fails, 2. Cleanup Completeness (Minor)In the // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
Object.keys(pendingPromises).forEach((key) => delete pendingPromises[key]);Consider: Since // Faster and cleaner - object will be GC'd anyway
for (const key in pendingPromises) {
delete pendingPromises[key];
}Or better yet, you don't need to manually delete at all - when the function scope ends, the object is garbage collected. The cleanup is only needed for the promise rejection, which you already handle above. 3. Connection State After Error (Minor)If try {
if (isClientConnected) {
await redisClient.quit();
}
} catch (error) {
console.error('Error closing Redis client:', error);
} finally {
isClientConnected = false; // Always mark as disconnected
}Performance Considerations✅ Positive Impact
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
96-96: Memory leak: use delete instead of setting to undefined.Setting
pendingPromises[key] = undefinedleaves the key in the map, accumulating undefined entries that prevent garbage collection. This contradicts the PR's cleanup improvements (line 310 usesdelete).Apply this diff:
pendingPromise.reject(new Error(`Key ${key} not found before stream ended`)); - pendingPromises[key] = undefined; + delete pendingPromises[key];
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
118-125: Consider avoiding dummy timer creation.Creating a resolved promise with a dummy
setTimeout(() => {}, 0)timer is wasteful. Consider using a sentinel value or refactoring the structure to avoid this.Example refactor to avoid the dummy timer:
} else { pendingPromises[normalizedKey] = { promise: Promise.resolve(parsedValue), resolve: () => {}, reject: () => {}, - timer: setTimeout(() => {}, 0), + timer: null as unknown as NodeJS.Timeout, // No timer needed for resolved promises resolved: true, // Mark as resolved immediately }; }Note: This requires adjusting the
PendingPromiseinterface to allow nullable timer or using a type assertion as shown.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts(5 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}
📄 CodeRabbit inference engine (CLAUDE.md)
Prettier is the sole authority for formatting all non-Ruby files; never manually format them
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use ESLint for JS/TS code (lint via rake lint or yarn lint)
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: lint-js-and-ruby
- GitHub Check: build
- GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22)
- GitHub Check: rspec-package-tests (3.2, latest)
- GitHub Check: rspec-package-tests (3.4, latest)
- GitHub Check: claude-review
- GitHub Check: rspec-package-tests (3.4, minimum)
- GitHub Check: rspec-package-tests (3.2, minimum)
🔇 Additional comments (6)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (6)
43-56: LGTM: Per-listener state successfully eliminates global state.The refactoring successfully moves all state to per-listener scope, addressing the core PR objective. Each listener now has its own Redis client and connection tracking.
132-144: LGTM: Proper use of ensureConnected() for cleanup.The function correctly ensures connection before deleting messages and properly clears the array afterward.
149-176: LGTM: Proper initialization and message processing.The function correctly checks for existing messages using
ensureConnected()and processes them appropriately.
181-227: LGTM: Robust stream listener setup.The stream listener correctly uses
ensureConnected(), implements proper blocking reads, and handles the continuous message processing loop appropriately.
248-292: LGTM: getValue maintains promise identity correctly.The method correctly maintains promise identity for React Suspense by returning the same promise for repeated calls, and properly handles timeouts and stream end scenarios.
300-310: LGTM: Proper cleanup of pending promises.The close method correctly clears timers (line 303) before rejecting promises and properly deletes keys from the map (line 310) instead of setting them to undefined, preventing memory leaks.
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
Outdated
Show resolved
Hide resolved
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
Outdated
Show resolved
Hide resolved
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Code Review: Refactor redisReceiver to eliminate global stateSummaryThis is an excellent refactoring that addresses critical issues in the redisReceiver module. The elimination of global state is architecturally sound and significantly improves the code's reliability, maintainability, and scalability. StrengthsArchitecture & Design ✅
Code Quality ✅
Potential Issues & RecommendationsCritical Issue: Connection Lifecycle
|
Code Review: Refactor redisReceiver to eliminate global stateOverall AssessmentThis is an excellent refactoring that significantly improves the architecture and reliability of the Redis receiver module. The changes effectively address real concurrency issues and memory leaks while maintaining backward compatibility. Strengths1. Architecture Improvements ✅
2. Concurrency Safety ✅The new async function ensureConnected(): Promise<RedisClientType> {
if (isClientConnected) return redisClient;
if (!connectionPromise) {
connectionPromise = redisClient.connect()
.then(() => { isClientConnected = true; connectionPromise = null; })
.catch((error) => { connectionPromise = null; throw error; });
}
await connectionPromise;
return redisClient;
}This properly handles race conditions where multiple async operations try to connect simultaneously. 3. Cleanup Logic ✅The enhanced
Areas for Improvement1. Connection Pooling Trade-off
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
98-104: Usedeleteinstead of setting toundefined.Line 102 sets
pendingPromises[key] = undefined, which contradicts the PR objective to "delete entries from maps (do not set toundefined)". Setting toundefinedprevents garbage collection of the key and leaves the entry in the object.Apply this diff:
if (pendingPromise && !pendingPromise.resolved) { clearTimeout(pendingPromise.timer); pendingPromise.reject(new Error(`Key ${key} not found before stream ended`)); - pendingPromises[key] = undefined; + delete pendingPromises[key]; }
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
43-44: Consider simplifying type by removingundefinedfrom union.The type
Record<string, PendingPromise | undefined>allowsundefinedvalues, but with consistent use ofdelete(after fixing line 102), entries are removed rather than set toundefined. You could simplify toRecord<string, PendingPromise>and rely on optional chaining orinchecks where needed.Current defensive checks (e.g.,
if (pendingPromise)) will still work, so this is purely a type consistency suggestion.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts(5 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}
📄 CodeRabbit inference engine (CLAUDE.md)
Prettier is the sole authority for formatting all non-Ruby files; never manually format them
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use ESLint for JS/TS code (lint via rake lint or yarn lint)
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
🧬 Code graph analysis (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
react_on_rails_pro/spec/dummy/client/node-renderer.js (1)
process(5-5)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: rspec-dummy-app-node-renderer
- GitHub Check: dummy-app-node-renderer-e2e-tests
🔇 Additional comments (4)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (4)
43-55: Excellent: per-listener state eliminates global state and enables request isolation.The dedicated Redis client and local state variables successfully achieve the PR's core objective of eliminating module-level globals. Each listener is now fully isolated.
57-84: LGTM: race condition fix properly handles connection failures.The
ensureConnected()implementation now correctly clearsconnectionPromisein the catch handler (lines 75-78), allowing retries after connection failures. This resolves the critical race condition flagged in the previous review.
303-339: LGTM: comprehensive cleanup properly handles pending connections.The
close()method now awaits any in-flightconnectionPromise(lines 318-326) before closing the Redis client, addressing the major issue from the previous review. The cleanup sequence—reject pending promises, delete entries, await connection, close client—ensures no resource leaks.
144-144: LGTM: consistent lazy connection pattern.The consistent use of
await ensureConnected()before Redis operations (lines 144, 161, 193) ensures proper connection handling and eliminates reliance on global client state.Also applies to: 161-161, 193-193
Code Review: Refactor redisReceiver to eliminate global stateOverall AssessmentThis is an excellent refactoring that significantly improves the architecture and reliability of the Redis receiver module. The changes effectively address memory leaks, race conditions, and concurrency issues by eliminating global state and moving to a request-scoped model. The code quality is high, and the approach is sound. ✅ Strengths1. Architecture Improvements
2. Code Quality
3. Resource Management
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (3)
99-104: Delete entries instead of setting to undefined.Line 103 sets
pendingPromises[key] = undefined, which violates the PR's explicit requirement to "delete entries from maps (do not set to undefined)". Undefined entries accumulate and prevent garbage collection.Apply this diff:
if (pendingPromise && !pendingPromise.resolved) { clearTimeout(pendingPromise.timer); pendingPromise.reject(new Error(`Key ${key} not found before stream ended`)); - pendingPromises[key] = undefined; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete pendingPromises[key]; }
124-133: Memory leak: resolved promises accumulate indefinitely.Lines 125-132 store already-resolved values as
PendingPromiseentries inpendingPromises, but these are never removed. Over the lifetime of a long-running listener, this map grows unbounded with stale resolved entries, causing a memory leak.Since the value has already arrived, store it in a separate
resolvedValuesmap or delete the entry after the promise resolves to allow garbage collection.Apply this diff to delete after resolution:
const pendingPromise = pendingPromises[normalizedKey]; if (pendingPromise) { clearTimeout(pendingPromise.timer); pendingPromise.resolve(parsedValue); pendingPromise.resolved = true; // Mark as resolved + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete pendingPromises[normalizedKey]; } else { + // Value arrived before getValue was called - store for immediate resolution pendingPromises[normalizedKey] = { promise: Promise.resolve(parsedValue), resolve: () => {}, reject: () => {}, timer: setTimeout(() => {}, 0), resolved: true, // Mark as resolved immediately }; }Note: If you choose to keep storing pre-resolved values for late
getValuecalls, add a comment explaining the retention policy and consider a cleanup strategy (e.g., TTL or size limit).
271-280: Memory leak: timed-out promises are never removed.The comment at line 278 states the intention to keep timed-out promises in the map, but this causes a memory leak. Each timeout rejection leaves a permanent entry in
pendingPromisesthat is never cleaned up, violating the PR's cleanup guarantees.Apply this diff to delete timed-out entries:
const timer = setTimeout(() => { const pendingPromise = pendingPromises[key]; if (pendingPromise) { pendingPromise.reject( new Error(`Timeout waiting for key: ${key}, available keys: ${receivedKeys.join(', ')}`), ); - // Keep the pending promise in the dictionary with the error state + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete pendingPromises[key]; } }, REDIS_READ_TIMEOUT);
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
45-45: Consider bounding receivedKeys to prevent unbounded growth.
receivedKeysaccumulates every key received (line 116) but is never cleared. For long-running listeners or high message volume, this array grows indefinitely. While most SSR listeners are short-lived, consider either clearing after a threshold or removing the array if it's only used for timeout error messages.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts(6 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}
📄 CodeRabbit inference engine (CLAUDE.md)
Prettier is the sole authority for formatting all non-Ruby files; never manually format them
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use ESLint for JS/TS code (lint via rake lint or yarn lint)
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
🧬 Code graph analysis (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
react_on_rails_pro/spec/dummy/client/node-renderer.js (1)
process(5-5)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: rspec-package-tests (3.4, latest)
- GitHub Check: rspec-package-tests (3.4, minimum)
- GitHub Check: rspec-package-tests (3.2, latest)
- GitHub Check: rspec-package-tests (3.2, minimum)
- GitHub Check: lint-js-and-ruby
- GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22)
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: build
- GitHub Check: claude-review
🔇 Additional comments (4)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (4)
43-56: LGTM! Per-listener state correctly eliminates global dependencies.The isolated state architecture (dedicated Redis client, pendingPromises map, connection tracking) successfully addresses the PR's core objective of eliminating module-level globals and enabling concurrent SSR without cross-request interference.
68-85: Connection race condition properly resolved.The
.catch()handler at lines 76-79 correctly clearsconnectionPromiseon failure, allowing subsequent retry attempts instead of awaiting a stale rejected promise. This addresses the critical race condition flagged in previous review.
139-234: LGTM! Redis operations correctly use per-listener connection.All stream operations (
xDel,xRead) properly invokeensureConnected()instead of relying on global state, ensuring complete request isolation.
317-337: Connection-in-progress properly handled during close.Lines 318-324 correctly await any pending
connectionPromisebefore attempting to close the Redis client, addressing the race condition flagged in the previous review where a connection could complete afterclose()returned.
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
Outdated
Show resolved
Hide resolved
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
Outdated
Show resolved
Hide resolved
|
@claude fix all the problems you see without changing the behavior |
|
Claude finished @AbanoubGhadban's task —— View job Fixed all identified issues ✅All problems identified in the code reviews have been fixed without changing behavior:
Changes MadeAll changes maintain existing behavior while eliminating memory leaks and race conditions:
All eslint-disable comments have been added where dynamic delete is used, as required by the linting rules. CommitThe changes have been committed and pushed to the PR branch:
| Branch |
- Use delete instead of undefined to properly remove map entries - Clean up resolved promises to prevent indefinite accumulation - Delete timed-out promises to avoid memory leaks - Add isActive check in getValue to prevent promises after close - Track lastProcessedId to prevent message loss with >100 messages All changes maintain existing behavior while fixing memory leaks and race conditions identified in code review. Co-authored-by: Abanoub Ghadban <AbanoubGhadban@users.noreply.github.com>
Code Review: Refactor redisReceiver to eliminate global stateOverall AssessmentThis is an excellent refactoring that significantly improves the architecture and reliability of the Redis stream listener. The elimination of global state and move to per-listener isolation is the right architectural decision. The code is well-structured, properly handles edge cases, and maintains backward compatibility. ✅ Strengths
🔍 Potential Issues & Questions1. Resource Consumption (Medium Priority)Issue: Each
Previous behavior: Shared single Redis client across all requests Questions:
Recommendation: Consider adding: // At module level
let activeConnectionCount = 0;
const MAX_CONNECTIONS = 100; // Configurable
// In listenToRequestData
if (activeConnectionCount >= MAX_CONNECTIONS) {
throw new Error(`Max Redis connections (${MAX_CONNECTIONS}) reached`);
}
activeConnectionCount++;
// ... and decrement in close()2. Missing State Tracking in
|
@claude make suggested changes |
|
Claude encountered an error —— View job Fixing all identified issues
|
There was a problem hiding this comment.
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)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
130-136: Minor: Pre-arrival values may accumulate if getValue is never called.If a message arrives before
getValueis called for its keys, the values are stored as resolved promises inpendingPromises. These entries persist untilclose()is called. While bounded by the number of unique keys in the stream, this could accumulate memory if many keys arrive but are never requested.Consider adding a comment documenting this behavior, or implement a cleanup strategy (e.g., TTL-based eviction or a maximum cache size):
} else { - // Value arrived before getValue was called - store for immediate resolution + // Value arrived before getValue was called - store for immediate resolution + // Note: These entries persist until close() unless getValue is called pendingPromises[normalizedKey] = {
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
206-234: Optional: Consider adding backoff to prevent log spam on persistent errors.The
readStreamloop retries immediately on error without backoff. If Redis is persistently unavailable, this will spam error logs. While theisActivecheck stops the loop whenclose()is called, adding exponential backoff would improve resilience.Example with simple backoff:
let retryDelay = 100; // Start with 100ms const readStream = async () => { if (!isActive || isEnded) { return; } try { const results = (await client.xRead( { key: streamKey, id: lastId }, { COUNT: 100, BLOCK: 1000 }, )) as RedisStreamResult[] | null; retryDelay = 100; // Reset delay on success // ... rest of processing } catch (error) { console.error('Error reading from stream:', error); retryDelay = Math.min(retryDelay * 2, 5000); // Exponential backoff, max 5s await new Promise(resolve => setTimeout(resolve, retryDelay)); } finally { void readStream(); } };
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts(10 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}
📄 CodeRabbit inference engine (CLAUDE.md)
Prettier is the sole authority for formatting all non-Ruby files; never manually format them
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use ESLint for JS/TS code (lint via rake lint or yarn lint)
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
- GitHub Check: claude-review
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22)
- GitHub Check: build-dummy-app-webpack-test-bundles (3.2, 20)
- GitHub Check: lint-js-and-ruby
- GitHub Check: build
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: rspec-package-tests (3.2, minimum)
- GitHub Check: rspec-package-tests (3.4, latest)
- GitHub Check: rspec-package-tests (3.4, minimum)
- GitHub Check: rspec-package-tests (3.2, latest)
🔇 Additional comments (2)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (2)
353-365: LGTM: Initialization properly handles errors and cleanup.The asynchronous initialization correctly:
- Processes existing messages before starting the stream listener
- Sets
initializationErroron failure sogetValuecan reject appropriately- Calls
close()to clean up resources on initialization failure- Has defensive error handling with the outer
.catch()This addresses the PR objective of proper cleanup and error handling.
42-368: Architecture successfully eliminates global state.The refactor achieves the PR objectives:
- ✅ Each
listenToRequestData()call creates an isolated listener with dedicated Redis client- ✅ No module-level globals (removed shared client, activeListeners, global pendingPromises)
- ✅ Proper cleanup:
deleteused for map entries, Redis client closed inclose()- ✅ Race condition fixed:
ensureConnected()serializes connection attempts per listener- ✅ Request isolation for concurrent SSR
The external API is preserved (getValue/close), and promise identity is maintained for React Suspense compatibility.
| const url = process.env.REDIS_URL || 'redis://localhost:6379'; | ||
| const redisClient: RedisClientType = createClient({ url }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Add error event listener to prevent process crash.
The Redis client requires at least one error listener; without it, unhandled errors will crash the Node.js process. This is a production-critical issue.
Apply this diff to add error handling immediately after client creation:
const url = process.env.REDIS_URL || 'redis://localhost:6379';
const redisClient: RedisClientType = createClient({ url });
+
+// MUST handle errors to prevent process crash
+redisClient.on('error', (err) => {
+ console.error('Redis client error:', err);
+ // Error will be handled by connection retry logic in ensureConnected
+});
+
let isClientConnected = false;
let connectionPromise: Promise<void> | null = null;🤖 Prompt for AI Agents
In react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts around lines
54 to 55, the Redis client is created without an error listener which can cause
unhandled errors to crash the Node.js process; add an error handler immediately
after createClient by attaching redisClient.on('error', handler) (use the
project's logger if available or console.error) to log the error and prevent
process termination, and ensure this listener is in place before calling
redisClient.connect() or using the client.
Code Review: Redis Receiver RefactoringI've reviewed this PR and the refactoring looks excellent overall. The elimination of global state is a significant improvement for concurrent SSR scenarios. Here's my detailed feedback: ✅ Strengths1. Architecture Improvements
2. Bug Fixes
3. API Improvements
🐛 Potential Issues1. Critical: Resource Leak in getValue() Loop (redisReceiver.ts:103-114)The infinite while (true) {
if (valuesMap.has(key)) {
return valuesMap.get(key);
}
await listenToStream(); // ⚠️ If stream ends without the key, this loops forever
}Problem: If Suggested fix: while (true) {
if (valuesMap.has(key)) {
return valuesMap.get(key);
}
if (isClosed) {
throw new Error(`Key "${key}" not found - stream ended`);
}
await listenToStream();
}2. Edge Case: Timeout After Destroy (redisReceiver.ts:127)The global timeout can fire after globalTimeout = setTimeout(() => {
void destroy();
}, REDIS_LISTENER_TIMEOUT);If Suggested fix: Clear the timeout in async function destroy(): Promise<void> {
if (isDestroyed) return;
isDestroyed = true;
clearTimeout(globalTimeout); // Move this line up
await close();
}3. Race Condition: Connect Promise Not Saved (redisReceiver.ts:59-62)The connection logic doesn't handle concurrent calls correctly: if (!isConnected) {
await redisClient.connect();
isConnected = true;
}Problem: If two Your test at redisClient.test.ts:29-36 correctly identifies this issue, but the code doesn't handle it. Suggested fix: let connectPromise: Promise<void> | null = null;
if (!isConnected) {
if (!connectPromise) {
connectPromise = redisClient.connect().then(() => {
isConnected = true;
connectPromise = null;
});
}
await connectPromise;
}4. Missing Stream Cleanup (redisReceiver.ts:72-95)The old code deleted processed messages with
Question: Is this intentional? Should we still delete processed messages, or rely on Redis TTL for the entire stream? ⚡ Performance Considerations1. One Client Per RequestTrade-off: Creating a new Redis client for every SSR request has pros and cons: Pros:
Cons:
Recommendation: This is fine for moderate traffic. If you see connection issues at scale, consider:
2. Blocking Read PatternUsing await redisClient.xRead({ key: streamKey, id: lastId }, { BLOCK: 0 })This is more efficient than polling, and the infinite loop in 🧪 Test CoverageStrengths:
Gaps:
Suggested test: test('getValue rejects if key not found before stream ends', async () => {
const { getValue, destroy } = listenToRequestData('test-request');
// Send end message without the key
await sendToStream('test-request', { end: 'true' });
await expect(getValue('missing-key')).rejects.toThrow('Key "missing-key" not found');
await destroy();
});🔒 Security Considerations1. Redis URL from Environmentconst url = process.env.REDIS_URL || 'redis://localhost:6379';This is fine for development but ensure production:
2. Stream Key Constructionconst streamKey = `stream:${requestId}`;Ensure 📝 Minor Improvements1. Error Message Enhancement (redisReceiver.ts:107-111)The error message includes the stack trace, which is great, but the message could be clearer: throw new Error(
`Error getting value for key "${key}": ${(error as Error).message}, stack: ${(error as Error).stack}`,
);Suggestion: Stack is already in the error object, no need to include it in the message: const err = new Error(`Failed to get value for key "${key}": ${(error as Error).message}`);
err.cause = error;
throw err;2. Type Safety (redisReceiver.ts:103)The // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {This is fine, but consider if there's a cleaner way to structure this that doesn't require the disable comment. 3. Promise Identity (redisReceiver.ts:115-118)Good! You're correctly caching promises to maintain identity for React Suspense: const valuePromise = valuePromises.get(key);
if (valuePromise) {
return valuePromise;
}This ensures React Suspense gets the same promise across renders. 📋 SummaryOverall Assessment: This is a solid refactoring that addresses real memory leak and race condition issues. The code is cleaner and more maintainable. Required Changes:
Recommended Changes:
Performance: The one-client-per-request approach is acceptable but monitor in production. Grade: B+ (would be A with the critical fixes) Great work on this refactoring! Once the critical issues are addressed, this will be a significant improvement to the codebase. Review generated with Claude Code - let me know if you'd like me to clarify any points or help implement the suggested fixes! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
47-49: Attach an error listener on the Redis client.
node-redisemits'error'events, and without a listener the process crashes. Add an error handler right aftercreateClientso unexpected network errors don’t tear down SSR.const url = process.env.REDIS_URL || 'redis://localhost:6379'; const redisClient: RedisClientType = createClient({ url }); + redisClient.on('error', (error) => { + console.error(`Redis listener error for request "${requestId}":`, error); + });
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/client/app/components/ErrorComponent.tsx (1)
10-10: Consider conditionally rendering the stack trace based on environment.Displaying error stack traces unconditionally can expose sensitive application internals (file paths, structure) in production. Consider showing the stack only in development and test environments.
Apply this diff to conditionally render the stack trace:
<h1>Error happened while rendering RSC Page</h1> <p>{error.message}</p> - <p>{error.stack}</p> + {(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') && ( + <pre>{error.stack}</pre> + )}Note: Using
<pre>for the stack trace preserves formatting for better readability. Based on learnings about including error stacks in development and test environments to aid debugging.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
react_on_rails_pro/packages/node-renderer/tests/redisClient.test.ts(1 hunks)react_on_rails_pro/spec/dummy/client/app/components/ErrorComponent.tsx(1 hunks)react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx(1 hunks)react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx(1 hunks)react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts(2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}
📄 CodeRabbit inference engine (CLAUDE.md)
Prettier is the sole authority for formatting all non-Ruby files; never manually format them
Files:
react_on_rails_pro/spec/dummy/client/app/components/ErrorComponent.tsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsxreact_on_rails_pro/packages/node-renderer/tests/redisClient.test.tsreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsxreact_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use ESLint for JS/TS code (lint via rake lint or yarn lint)
Files:
react_on_rails_pro/spec/dummy/client/app/components/ErrorComponent.tsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsxreact_on_rails_pro/packages/node-renderer/tests/redisClient.test.tsreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsxreact_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
🧠 Learnings (11)
📚 Learning: 2024-12-12T13:07:09.929Z
Learnt from: alexeyr-ci
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/ReactOnRailsRSC.ts:87-87
Timestamp: 2024-12-12T13:07:09.929Z
Learning: When handling errors in 'node_package/src/ReactOnRailsRSC.ts', include the error stack in error messages in development and test environments to aid debugging.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/components/ErrorComponent.tsxreact_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
📚 Learning: 2025-06-09T07:58:02.646Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1696
File: node_package/src/RSCPayloadGenerator.ts:124-168
Timestamp: 2025-06-09T07:58:02.646Z
Learning: In React Server Components (RSC) implementations, explicit error handling in RSC payload generation streams (like in RSCPayloadGenerator.ts) is not needed because errors will propagate to client components that fetch the RSC payloads (such as RSCRoute components). React's built-in error handling mechanisms and ErrorBoundary components are sufficient for handling these errors at the component level, which allows for better user experience with appropriate error UI fallbacks.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/components/ErrorComponent.tsx
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx
📚 Learning: 2025-07-08T05:57:29.630Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1745
File: node_package/src/RSCRequestTracker.ts:8-14
Timestamp: 2025-07-08T05:57:29.630Z
Learning: The global `generateRSCPayload` function in React on Rails Pro (RORP) is provided by the framework during rendering requests, not implemented in application code. The `declare global` statements are used to document the expected interface that RORP will inject at runtime.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx
📚 Learning: 2025-09-16T08:01:11.146Z
Learnt from: justin808
Repo: shakacode/react_on_rails PR: 1770
File: lib/generators/react_on_rails/templates/base/base/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.jsx:2-2
Timestamp: 2025-09-16T08:01:11.146Z
Learning: React on Rails uses webpack CSS Modules configuration with namedExports: true, which requires the import syntax `import * as style from './file.module.css'` rather than the default export pattern. This configuration enables better tree shaking and bundle size optimization for CSS modules.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx
📚 Learning: 2025-02-18T13:08:01.477Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation is handled in deeper level calls of the React on Rails Pro codebase, so it doesn't need to be validated again in the `rsc_payload_react_component` helper method.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx
📚 Learning: 2024-10-08T20:53:47.076Z
Learnt from: theforestvn88
Repo: shakacode/react_on_rails PR: 1620
File: spec/dummy/client/app/startup/HelloTurboStream.jsx:3-3
Timestamp: 2024-10-08T20:53:47.076Z
Learning: The `RailsContext` import in `spec/dummy/client/app/startup/HelloTurboStream.jsx` is used later in the project, as clarified by the user theforestvn88.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsxreact_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx
📚 Learning: 2025-04-26T21:55:55.874Z
Learnt from: alexeyr-ci2
Repo: shakacode/react_on_rails PR: 1732
File: spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx:40-44
Timestamp: 2025-04-26T21:55:55.874Z
Learning: In the react_on_rails project, files under `app-react16` directories are copied/moved to corresponding `/app` directories during the conversion process (removing the `-react16` suffix), which affects their relative import paths at runtime.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx
📚 Learning: 2025-09-15T21:24:48.207Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx
📚 Learning: 2025-02-18T13:08:01.477Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation in React on Rails Pro is handled through a chain of validations:
1. Pro version check in `run_stream_inside_fiber`
2. RSC support check during pack generation via `ReactOnRailsPro.configuration.enable_rsc_support`
3. RSC support validation during component registration
This makes additional validation in the helper methods unnecessary.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx
📚 Learning: 2025-04-09T12:56:10.756Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1696
File: node_package/src/RSCPayloadContainer.ts:0-0
Timestamp: 2025-04-09T12:56:10.756Z
Learning: In the react_on_rails codebase, RSC payloads are already stringified using `JSON.stringify()` before being processed by the `escapeScript` function, which handles escaping of special characters. The function only needs to handle specific HTML markers like comments and closing script tags.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx
🧬 Code graph analysis (4)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx (2)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx (1)
listenToRequestData(37-37)react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
listenToRequestData(34-193)
react_on_rails_pro/packages/node-renderer/tests/redisClient.test.ts (1)
react_on_rails_pro/spec/dummy/client/node-renderer.js (1)
process(5-5)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx (2)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx (1)
listenToRequestData(6-6)react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
listenToRequestData(34-193)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (2)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx (1)
listenToRequestData(6-6)react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx (2)
listenToRequestData(37-37)value(7-7)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
- GitHub Check: dummy-app-integration-tests (3.4, 22)
- GitHub Check: dummy-app-integration-tests (3.2, 20)
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: rspec-package-tests (3.2, latest)
- GitHub Check: rspec-package-tests (3.2, minimum)
- GitHub Check: rspec-package-tests (3.4, latest)
- GitHub Check: claude-review
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: rspec-package-tests (3.4, minimum)
- GitHub Check: lint-js-and-ruby
- GitHub Check: build
🔇 Additional comments (2)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx (1)
36-41: API rename looks good.Switching to
destroykeeps this component aligned with the updated listener contract.react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx (1)
5-14: Clean rename todestroy.The consumer now matches the new
{ getValue, destroy }signature with no behavioral change.
| test('Redis client connects successfully', async () => { | ||
| await redisClient.connect(); | ||
| expect(redisClient.isOpen).toBe(true); | ||
| await redisClient.quit(); | ||
| }); | ||
|
|
||
| test('calls connect after quit', async () => { | ||
| await redisClient.connect(); | ||
| expect(redisClient.isOpen).toBe(true); | ||
| await redisClient.quit(); | ||
|
|
||
| await redisClient.connect(); | ||
| expect(redisClient.isOpen).toBe(true); | ||
| await redisClient.quit(); | ||
| }); | ||
|
|
||
| test('calls quit before connect is resolved', async () => { | ||
| const client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' }); | ||
| const connectPromise = client.connect(); | ||
| await client.quit(); | ||
| await connectPromise; | ||
| expect(client.isOpen).toBe(false); | ||
| }); | ||
|
|
||
| test('multiple connect calls', async () => { | ||
| const client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' }); | ||
| const connectPromise1 = client.connect(); | ||
| const connectPromise2 = client.connect(); | ||
| await expect(connectPromise2).rejects.toThrow('Socket already opened'); | ||
| await expect(connectPromise1).resolves.toMatchObject({}); | ||
| expect(client.isOpen).toBe(true); | ||
| await client.quit(); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guarantee Redis client cleanup even on assertion failures.
If an expectation throws before you hit quit(), Jest keeps the socket open and the suite hangs on teardown. Wrap each test’s connect logic in a try/finally (or add shared afterEach/afterAll cleanup) so we always close the client—even when assertions fail.
test('Redis client connects successfully', async () => {
- await redisClient.connect();
- expect(redisClient.isOpen).toBe(true);
- await redisClient.quit();
+ await redisClient.connect();
+ try {
+ expect(redisClient.isOpen).toBe(true);
+ } finally {
+ await redisClient.quit().catch(() => redisClient.disconnect());
+ }
});Please apply the same pattern to the other tests that open their own clients.
Committable suggestion skipped: line range outside the PR's diff.
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
Outdated
Show resolved
Hide resolved
| globalTimeout = setTimeout(() => { | ||
| void destroy(); | ||
| }, REDIS_LISTENER_TIMEOUT); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle destroy() rejection from the auto-timeout.
setTimeout(() => void destroy()) drops any rejection from destroy(), leaving an unhandled rejection that will crash Node ≥16. Capture the promise and log/ignore failures instead.
- globalTimeout = setTimeout(() => {
- void destroy();
- }, REDIS_LISTENER_TIMEOUT);
+ globalTimeout = setTimeout(() => {
+ destroy().catch((error) => {
+ console.error(`Failed to destroy Redis listener for "${requestId}":`, error);
+ });
+ }, REDIS_LISTENER_TIMEOUT);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| globalTimeout = setTimeout(() => { | |
| void destroy(); | |
| }, REDIS_LISTENER_TIMEOUT); | |
| globalTimeout = setTimeout(() => { | |
| destroy().catch((error) => { | |
| console.error(`Failed to destroy Redis listener for "${requestId}":`, error); | |
| }); | |
| }, REDIS_LISTENER_TIMEOUT); |
🤖 Prompt for AI Agents
In react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts around lines
188–190, the setTimeout handler calls destroy() with void which drops any
promise rejection and can produce an unhandled rejection on Node >=16; change
the timeout callback to capture the returned promise and handle failures (e.g.
call destroy().catch(err => { /* log or ignore */ }) or await destroy() inside
an async wrapper), ensuring any rejection is caught and either logged via the
existing logger or safely ignored.
Code Review - PR #1894SummaryThis is an excellent refactoring that successfully eliminates global state and fixes critical memory leak and race condition issues. The architecture change from shared state to per-request isolation is sound and well-executed. Overall: APPROVED with minor suggestions. ✅ Strengths1. Excellent Architecture Improvement
2. Proper Memory Management
3. Race Condition Fixes
4. Good Test Coverage
🐛 Potential Bugs & IssuesCritical: Infinite Loop RiskLocation: while (true) {
if (valuesMap.has(key)) {
return valuesMap.get(key);
}
await listenToStream();
}Issue: If the stream ends ( Current behavior: When Problem: The error message might be confusing. Consider making it clearer that the key was never found because the stream ended. Suggestion: try {
while (true) {
if (valuesMap.has(key)) {
return valuesMap.get(key);
}
await listenToStream();
}
} catch (error) {
if (isClosed) {
throw new Error(`Key "${key}" not found - stream ended before value arrived`);
}
throw new Error(
`Error getting value for key "${key}": ${(error as Error).message}`
);
}Medium: Missing Message DeletionObservation: The old code deleted messages from the stream after processing ( Questions:
Recommendation: Document why message deletion was removed, or add it back if it was an oversight. Medium: Error Stack Trace ExposureLocation: <p>{error.stack}</p>Issue: Displaying full stack traces in production can leak sensitive information about server architecture, file paths, and implementation details. Recommendation: Only show stack traces in development: {process.env.NODE_ENV === 'development' && <p>{error.stack}</p>}Low: eslint-disable CommentsLocations: // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
// ...
// eslint-disable-next-line no-await-in-loop
await listenToStream();
}Observation: While technically correct, these disabled rules might indicate a pattern that could be refactored. Alternative approach: Consider using a recursive async function instead: async function waitForKey(): Promise<unknown> {
if (valuesMap.has(key)) {
return valuesMap.get(key);
}
await listenToStream();
return waitForKey();
}
return waitForKey();This eliminates the need for eslint disables and might be more idiomatic. ⚡ Performance ConsiderationsGood: Connection Reuse
Concern: One Client Per Request
Question: Have you benchmarked the connection overhead under load? Creating/destroying Redis connections for every SSR request could be expensive. Suggestion: Consider connection pooling if performance testing shows this is an issue. However, the current approach might be acceptable if:
Good: Blocking Readawait redisClient.xRead(
{ key: streamKey, id: lastId },
{ BLOCK: 0 }, // Block indefinitely
)Using 🔒 Security ConcernsLow: TTL Configurationconst REDIS_LISTENER_TIMEOUT = 15000; // 15 seconds
await redisClient.expire(streamKey, REDIS_LISTENER_TIMEOUT / 1000);Observation: 15-second TTL seems reasonable for SSR scenarios, but this is a critical timeout. Questions:
Recommendation: Consider making this configurable via environment variable: const REDIS_LISTENER_TIMEOUT = parseInt(process.env.REDIS_LISTENER_TIMEOUT || '15000', 10);Good: Error Message SafetyError messages don't leak sensitive data, though the stack trace issue mentioned above should be addressed. 🧪 Test CoverageGood Coverage in
|
| Category | Rating | Notes |
|---|---|---|
| Code Quality | ⭐⭐⭐⭐⭐ | Clean, well-structured refactoring |
| Architecture | ⭐⭐⭐⭐⭐ | Excellent move to request-scoped state |
| Bug Fixes | ⭐⭐⭐⭐☆ | Fixes major issues, minor edge cases remain |
| Performance | ⭐⭐⭐⭐☆ | Good, but needs benchmarking under load |
| Security | ⭐⭐⭐⭐☆ | Mostly good, fix stack trace exposure |
| Tests | ⭐⭐⭐⭐☆ | Good client tests, needs listener tests |
Overall: APPROVED ✅
This is a high-quality refactoring that addresses real problems. Address the critical infinite loop error handling and consider the other suggestions, but the core changes are solid.
Reviewed with Claude Code 🤖
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (2)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (2)
48-48: CRITICAL: Add error event listener to prevent process crash.The Redis client requires at least one error listener; without it, unhandled errors will crash the Node.js process. This is a production-critical issue that was flagged in previous reviews and remains unaddressed.
Apply this diff to add error handling immediately after client creation:
const url = process.env.REDIS_URL || 'redis://localhost:6379'; const redisClient: RedisClientType = createClient({ url }); + +// Handle errors to prevent process crash +redisClient.on('error', (err) => { + console.error(`Redis client error for stream "${streamKey}":`, err); +}); + let isConnected = false;
189-191: Add error handling to auto-destroy timeout.While
destroy()is now synchronous and unlikely to throw, defensive coding suggests wrapping the call in try-catch to prevent unhandled exceptions from the timer callback from crashing the process.Apply this diff:
// Global timeout - destroys listener after 15 seconds globalTimeout = setTimeout(() => { - destroy(); + try { + destroy(); + } catch (error) { + console.error(`Error during auto-destroy of listener for "${requestId}":`, error); + } }, REDIS_LISTENER_TIMEOUT);
🧹 Nitpick comments (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (1)
174-193: Consider initializing globalTimeout to avoid undefined state.
globalTimeoutis declared (line 174) but not assigned until line 189. Ifdestroy()is somehow called synchronously before line 189 executes,clearTimeout(globalTimeout)receivesundefined, which is safe but semantically unclear. Consider either usinglet globalTimeout: NodeJS.Timeout | null = nullwith a null check, or restructure to assign immediately.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
react_on_rails_pro/packages/node-renderer/tests/redisClient.test.ts(1 hunks)react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- react_on_rails_pro/packages/node-renderer/tests/redisClient.test.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{js,jsx,ts,tsx,css,scss,json,yml,yaml,md}
📄 CodeRabbit inference engine (CLAUDE.md)
Prettier is the sole authority for formatting all non-Ruby files; never manually format them
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Use ESLint for JS/TS code (lint via rake lint or yarn lint)
Files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
🧠 Learnings (1)
📚 Learning: 2024-12-12T13:07:09.929Z
Learnt from: alexeyr-ci
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/ReactOnRailsRSC.ts:87-87
Timestamp: 2024-12-12T13:07:09.929Z
Learning: When handling errors in 'node_package/src/ReactOnRailsRSC.ts', include the error stack in error messages in development and test environments to aid debugging.
Applied to files:
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
🧬 Code graph analysis (1)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (2)
react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RSCPostsPageOverRedis.jsx (1)
listenToRequestData(6-6)react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx (2)
listenToRequestData(37-37)value(7-7)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: claude-review
- GitHub Check: build
- GitHub Check: lint-js-and-ruby
- GitHub Check: rspec-package-tests (3.4, latest)
- GitHub Check: build-dummy-app-webpack-test-bundles
- GitHub Check: rspec-package-tests (3.4, minimum)
- GitHub Check: rspec-package-tests (3.2, latest)
- GitHub Check: rspec-package-tests (3.2, minimum)
🔇 Additional comments (4)
react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts (4)
1-27: LGTM: Clean type definitions and constants.The interface changes (destroy replacing close) align with PR objectives, and the timeout constant is well-named.
138-172: LGTM: getValue correctly handles lifecycle and preserves promise identity.The implementation properly checks
isDestroyed, maintains promise identity for React Suspense, and includes error stack traces for debugging per the project's learning. The loop pattern withlistenToStream()will correctly terminate when the stream closes or an error occurs.Based on learnings
113-117: Verify the Redis stream producer key format contract.The code removes leading
:from keys (line 114), which appears intentional. However, without access to the Redis stream producer code or API contract documentation, I cannot confirm whether this normalization matches the contract. The code anticipates keys both with and without the prefix (key.startsWith(':')), suggesting this is deliberate, but the producer's actual key format should be verified.Search within the codebase produced no clear results showing where stream keys are produced or documented. Manual verification with the backend producer code or API specification is needed.
54-64: No critical race condition found; current implementation has mitigations, but the suggestion has merit.The code uses a mutual exclusion pattern via
listenToStreamPromisewhere only one promise executes at a time. Ifclose()is called duringawait redisClient.connect(), the synchronousdestroy()call will interrupt the pending connection, the exception propagates to reject the promise, andisConnectedremains false. Thefinallyblock inclose()ensures cleanup completes.However, the suggestion to await
listenToStreamPromisebefore destroying the client is reasonable for explicit cleanup, particularly if the Redis client library doesn't guarantee interrupt semantics. The current tests do not cover this concurrent close-during-connection scenario.Consider either:
- Adding a guard to await any pending
listenToStreamPromiseinclose()with error suppression for explicit cleanup, or- Adding a test case verifying behavior when
destroy()is called during in-progress connection to confirm interrupt guarantees.
| function listenToStream(): Promise<void> { | ||
| // Return existing promise if already listening | ||
| if (listenToStreamPromise) { | ||
| return listenToStreamPromise; | ||
| } | ||
|
|
||
| try { | ||
| const client = await getRedisClient(); | ||
|
|
||
| // Read all messages from the beginning of the stream | ||
| const results = (await client.xRead({ key: streamKey, id: '0' }, { COUNT: 100 })) as | ||
| | RedisStreamResult[] | ||
| | null; | ||
| // Create new listening promise | ||
| listenToStreamPromise = (async (): Promise<void> => { | ||
| if (isClosed) { | ||
| throw new Error('Redis Connection is closed'); | ||
| } | ||
|
|
||
| if (results && Array.isArray(results) && results.length > 0) { | ||
| const [{ messages }] = results; | ||
| // redisClient.connect(); is called only here | ||
| // And `listenToStream` runs only one promise at a time, so no fear of race condition | ||
| if (!isConnected) { | ||
| await redisClient.connect(); | ||
| await redisClient.expire(streamKey, REDIS_LISTENER_TIMEOUT / 1000); // Set TTL to avoid stale streams | ||
| isConnected = true; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CRITICAL: Race condition allows concurrent connection attempts.
Lines 73-78 have a time-of-check to time-of-use (TOCTOU) race: if listenToStream() is called concurrently before listenToStreamPromise is assigned (line 78), both calls pass the null check at line 73 and create separate promises. Both will execute redisClient.connect() concurrently (line 86), causing the second connect to fail or producing undefined behavior.
Apply this diff to fix the race:
function listenToStream(): Promise<void> {
// Return existing promise if already listening
if (listenToStreamPromise) {
return listenToStreamPromise;
}
- // Create new listening promise
- listenToStreamPromise = (async (): Promise<void> => {
+ // Create new listening promise and assign immediately to prevent race
+ const promise = (async (): Promise<void> => {
if (isClosed) {
throw new Error('Redis Connection is closed');
}
// redisClient.connect(); is called only here
// And `listenToStream` runs only one promise at a time, so no fear of race condition
if (!isConnected) {
await redisClient.connect();
await redisClient.expire(streamKey, REDIS_LISTENER_TIMEOUT / 1000); // Set TTL to avoid stale streams
isConnected = true;
}
// xRead blocks indefinitely until message arrives
const result = (await redisClient.xRead(
{ key: streamKey, id: lastId },
{ BLOCK: 0 }, // Block indefinitely
)) as RedisStreamResult[] | null;
if (!result || result.length === 0) {
return;
}
const [{ messages }] = result;
let receivedEndMessage = false;
for (const { id, message } of messages) {
lastId = id;
// Check for end message
if ('end' in message) {
receivedEndMessage = true;
}
// Process key-value pairs
Object.entries(message).forEach(([key, value]) => {
const normalizedKey = key.startsWith(':') ? key.substring(1) : key;
const parsedValue = JSON.parse(value) as unknown;
valuesMap.set(normalizedKey, parsedValue);
});
}
// If end message received, close the connection
if (receivedEndMessage) {
close();
}
})();
+ listenToStreamPromise = promise;
- return listenToStreamPromise.finally(() => {
+ return promise.finally(() => {
// Reset so next call creates new promise
listenToStreamPromise = null;
});
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts around lines
71 to 89, the current null-check then later assignment of listenToStreamPromise
allows concurrent callers to create separate promises and race on
redisClient.connect(); fix by assigning listenToStreamPromise immediately before
performing any async work so subsequent callers see the same promise (e.g.
assign listenToStreamPromise = (async () => { ... })() synchronously), and
ensure you clear/replace listenToStreamPromise on final completion or on error
so retries work correctly; do not change the public behavior beyond preventing
concurrent connect attempts.
Code Review: Refactor redisReceiver to eliminate global state✅ StrengthsExcellent architectural improvement - This refactor successfully addresses critical issues with global state and memory leaks. The new per-listener isolation is a significant improvement. Good test coverage - The new redisClient.test.ts provides comprehensive coverage of Redis client behavior including edge cases like concurrent connect/quit and blocking operations. Clear documentation - The PR description is thorough, changes are well-documented in comments, and the benefit/risk trade-offs are clearly explained. |
🔴 Critical Issues1. Race condition in connection establishment (redisReceiver.ts:85-89)The connection logic has a subtle race condition when multiple if (!isConnected) {
await redisClient.connect();
await redisClient.expire(streamKey, REDIS_LISTENER_TIMEOUT / 1000);
isConnected = true;
}Problem: If two Evidence: Your own test in redisClient.test.ts:40-47 shows that concurrent Fix suggestion: Use a connection promise pattern to serialize connection attempts. |
2. Missing error handling for JSON.parse (redisReceiver.ts:115)Problem: If malformed JSON arrives from Redis, JSON.parse will throw an unhandled exception that will reject ALL pending getValue() promises with a generic error. This could crash server-side rendering. Fix: Add try-catch with proper error handling around the JSON.parse call to handle malformed data gracefully. |
3. Potential infinite loop (redisReceiver.ts:155)The while (true) loop in getValue() has no escape mechanism if:
While there is a global timeout, if getValue() is called after the timeout expires but before destroy() is triggered, the promise will hang. Fix: Check isClosed in the loop condition instead of while (true). |
|
🧪 Test Coverage GapsWhile redisClient.test.ts tests Redis client behavior, there are no direct tests for the new listenToRequestData() implementation:
Recommendation: Add integration tests that exercise the full listenToRequestData() functionality including edge cases and error scenarios. |
🔒 Security & Performance ConsiderationsSecurity:
Performance:
Minor improvements:
|
📝 SummaryOverall assessment: This is a solid refactor that addresses real architectural problems. The elimination of global state is the right approach and will prevent memory leaks and race conditions in production. Before merging, please address:
After addressing these issues, this PR will be ready to merge. Great work on improving the architecture and thank you for the thorough documentation! Review performed following repository CLAUDE.md guidelines |
Code Review - PR #1894: Refactor redisReceiver to eliminate global stateOverall AssessmentThis is a high-quality refactor that successfully addresses critical concurrency and memory leak issues. The architecture change from global shared state to request-scoped instances is well-executed and significantly improves code safety. ✅ Strengths
🔍 Issues & SuggestionsCritical Issues1. Infinite Loop Risk in while (true) {
if (valuesMap.has(key)) {
return valuesMap.get(key);
}
await listenToStream();
}Problem: If a key is never received and no end message arrives, this loops forever. The global timeout (15s) will eventually call Fix: After Recommended Solution:
2. Missing Cleanup of The 3. Race Condition in function destroy() {
if (isDestroyed) return;
isDestroyed = true;
clearTimeout(globalTimeout);
close();
}If
This is likely fine in practice, but worth noting. Medium Issues4. Missing Error Handling in The
Suggestion: Add structured error logging and consider retry logic for transient errors. 5. let globalTimeout: NodeJS.Timeout;
// ...
globalTimeout = setTimeout(() => {
destroy();
}, REDIS_LISTENER_TIMEOUT);
const globalTimeout = setTimeout(() => destroy(), REDIS_LISTENER_TIMEOUT);This also makes it clear that 6. Inconsistent Timeout Values
Question: Is 15 seconds appropriate for all SSR scenarios? Long-running data fetches might exceed this. Consider making it configurable. Minor Issues7. Typo in Comment (redisReceiver.ts:43) // True when user explictly calls destroyShould be "explicitly" not "explictly". 8. Test Coverage Gaps The new
9. Missing For complete cleanup, consider clearing the maps in function destroy() {
if (isDestroyed) return;
isDestroyed = true;
clearTimeout(globalTimeout);
valuesMap.clear();
valuePromises.clear();
close();
}Performance Considerations10. Blocking Forever with const result = await redisClient.xRead(
{ key: streamKey, id: lastId },
{ BLOCK: 0 }, // Block indefinitely
);While this is correct for the use case, it means if no messages arrive, the connection stays open until the 15s timeout. This is fine but worth documenting. 11. No Connection Pooling Each request creates a new Redis client. For high-concurrency scenarios, this could lead to many simultaneous connections. Consider:
🔒 Security Considerations12. Redis URL from Environment const url = process.env.REDIS_URL || 'redis://localhost:6379';This is standard practice. No security issues here, but ensure:
13. JSON Parsing without Validation (redisReceiver.ts:115) const parsedValue = JSON.parse(value) as unknown;No validation of the parsed value. If malicious data is injected into Redis, it will be returned as-is. Consider:
📋 Documentation & Style14. API Change: The rename from /**
* Destroys the listener permanently, closing the connection and preventing further getValue calls.
* This operation is irreversible - the listener cannot be reused after destroy() is called.
*/15. Comment Clarity (redisReceiver.ts:32) * @returns An object with a getValue function to get values by key and a close functionShould be updated to mention 🎯 RecommendationsHigh Priority:
Medium Priority: Low Priority: ConclusionThis is a solid refactor that meaningfully improves the codebase. The core issues it addresses (global state, memory leaks, race conditions) are real and important. The implementation is mostly sound, with a few areas that need attention (primarily the infinite loop handling). Recommendation: Request changes for the critical issue (#1), then approve. The other issues can be addressed in follow-up PRs if needed. Great work on this refactor! The architecture is much cleaner and safer. 🎉 |

Summary
Refactors the
redisReceivermodule to eliminate all module-level global state and move to request-scoped encapsulation. This fixes memory leaks and race conditions in concurrent server-side rendering scenarios.Changes
Eliminated Global State:
sharedRedisClient- single Redis client shared across all requestsactiveListeners- global map tracking all active listenerspendingPromises- global promise trackingNew Architecture:
listenToRequestData()call creates its own dedicated Redis clientpendingPromises,receivedKeys,isActive, etc.) is scoped to the listener instanceBug Fixes:
ensureConnected()where multiple concurrent calls would failundefinedentries in mapsclose()methodBenefits
Testing
Related
Fixes #1893 (if exists)
🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com
This change is
Summary by CodeRabbit
Refactor
Bug Fixes
Tests
User-facing API
UI
Chores