[FTF-198] Send close code on react-web-cli panel unmount#128
[FTF-198] Send close code on react-web-cli panel unmount#128denissellu wants to merge 2 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughModifies AblyCliTerminal component to close WebSocket connections with code 4001 and reason "user-closed-panel" when unmounting, signaling intentional shutdown to enable server session cleanup. Adds test coverage for unmount cleanup behavior. Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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)
packages/react-web-cli/src/AblyCliTerminal.tsx (1)
3556-3564: Add close code and reason to secondary WebSocket cleanup for consistency.The secondary WebSocket at line 3563 closes without an explicit close code, defaulting to 1000, while the primary socket uses close code 4001 with reason "user-closed-panel" at line 2425. This inconsistency should be addressed.
Consider updating line 3563 to use an explicit close code and reason string (e.g.,
close(1000, "user-closed-panel")orclose(4001, "user-closed-panel")) to match the pattern established for primary socket cleanup. This ensures both sockets signal their closure to the server in the same manner.
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Jira integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
packages/react-web-cli/src/AblyCliTerminal.test.tsxpackages/react-web-cli/src/AblyCliTerminal.tsx
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (.cursor/rules/Development.mdc)
**/*.{ts,tsx}: Use TypeScript and follow best practice naming conventions
ESLint is used to ensure all code adheres best practices; ensure you check that all code passes the linter before concluding your work.
Files:
packages/react-web-cli/src/AblyCliTerminal.tsxpackages/react-web-cli/src/AblyCliTerminal.test.tsx
🧬 Code graph analysis (2)
packages/react-web-cli/src/AblyCliTerminal.tsx (1)
packages/react-web-cli/src/terminal-shared.ts (1)
debugLog(311-315)
packages/react-web-cli/src/AblyCliTerminal.test.tsx (1)
packages/react-web-cli/src/AblyCliTerminal.tsx (1)
AblyCliTerminal(3838-3841)
⏰ 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). (5)
- GitHub Check: auth-tests
- GitHub Check: session-tests
- GitHub Check: ui-tests
- GitHub Check: Cursor Bugbot
- GitHub Check: e2e-cli
🔇 Additional comments (2)
packages/react-web-cli/src/AblyCliTerminal.tsx (1)
2418-2426: LGTM - Clear intent and proper cleanup.The unmount logic correctly closes the WebSocket with code 4001 to signal intentional closure, and the guard condition properly checks for sockets that are still active (CONNECTING or OPEN states). The inline comment clearly documents the server-side cleanup behavior.
packages/react-web-cli/src/AblyCliTerminal.test.tsx (1)
2471-2514: LGTM - Comprehensive test coverage for unmount cleanup.The test suite appropriately covers both the standard unmount case (socket open → close with 4001) and the edge case where the socket is already closing. The test structure is clear and the assertions directly verify the implementation's behavior.
f7a6f37 to
59b61d0
Compare
| // This tells the server to cleanup immediately (no grace period) | ||
| debugLog("[AblyCLITerminal] Closing WebSocket on unmount."); | ||
| socketReference.current.close(); | ||
| socketReference.current.close(4001, "user-closed-panel"); |
There was a problem hiding this comment.
I believe the intention here is that if we send 4001 to the terminal server, it just should just remove the session immediately, rather than wait for natural timeout. This is fine in principle for when a user explicitly clicks something, but doing this here would force the close on any unmount, which could happen between pages? We certainly don't want to limit navigation options to just non-reloading.
3beadba to
f9e5862
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
This PR is being reviewed by Cursor Bugbot
Details
Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
f9e5862 to
dbb4dcb
Compare
…t graceful Unmount uses a normal close() so the server grace period preserves session resumability on navigation/reload. A new terminateSession() method on the AblyCliTerminalHandle ref sends close(4001, "user-closed-panel") for immediate server cleanup when the user explicitly closes the panel.
dbb4dcb to
6d67b86
Compare
There was a problem hiding this comment.
Pull request overview
Adjusts AblyCliTerminal WebSocket close behavior to preserve resumable sessions on navigation/reload, while still allowing explicit “destroy session now” behavior when the user closes the panel.
Changes:
- Change unmount cleanup to perform a normal WebSocket close (no
4001) to preserve server-side resume grace period. - Add
terminateSession()to theAblyCliTerminalHandleimperative API to explicitly close with4001and a reason. - Add unit tests for unmount close behavior and
terminateSession()close semantics.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
packages/react-web-cli/src/AblyCliTerminal.tsx |
Adds terminateSession() and changes unmount close from a special code to a normal close for resume support. |
packages/react-web-cli/src/AblyCliTerminal.test.tsx |
Adds tests validating unmount cleanup and explicit session termination behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ) { | ||
| // close websocket | ||
| // Normal close (no 4001) so server grace period allows resume | ||
| debugLog("[AblyCLITerminal] Closing WebSocket on unmount."); |
There was a problem hiding this comment.
Calling socketReference.current.close() on unmount will produce a normal CloseEvent (likely code 1000, empty reason). In handleWebSocketClose, that path is treated as recoverable and schedules auto-reconnect, which can continue after the component has unmounted (since grResetState() does not cancel timers unless already cancelled). Consider ensuring unmount close is treated as a user-initiated/no-reconnect close (e.g., close with an explicit reason that handleWebSocketClose short-circuits on, or set an isUnmounting flag checked by the close handler / cancel reconnect timers before closing).
| debugLog("[AblyCLITerminal] Closing WebSocket on unmount."); | |
| debugLog("[AblyCLITerminal] Closing WebSocket on unmount."); | |
| // Prevent auto-reconnect and handler side effects after unmount | |
| grCancelReconnect(); | |
| socketReference.current.onclose = null; | |
| socketReference.current.onerror = null; |
There was a problem hiding this comment.
@coderabbitai what do you think about above review comment, can you explain a bit more
There was a problem hiding this comment.
@sacOO7 Great question! Let me break down Copilot's concern and the broader context here.
Two Different Scenarios
Based on the code, there are two distinct ways the WebSocket gets closed:
-
Explicit termination (via
terminateSession()method):- Uses code
4001and reason"user-closed-panel" - Signals to the server that the session should be terminated immediately
- Intended for when the user actively closes the panel
- Uses code
-
Component unmount (cleanup in
useEffect):- Currently uses normal close (
code 1000, no reason) - Allows the server to keep the session alive with a grace period for potential resumption
- Happens when the component is unmounted (e.g., page navigation, React re-render)
- Currently uses normal close (
The Problem Copilot Identified
When the component unmounts and calls socketReference.current.close() without arguments, it produces a CloseEvent with code 1000 (normal closure) and an empty reason.
Looking at your handleWebSocketClose function (around line 1645), it checks for specific reasons to avoid reconnecting:
const userClosedTerminal =
event.reason === "user-closed-primary" ||
event.reason === "user-closed-secondary" ||
event.reason === "manual-reconnect";Since the unmount close has no reason and code 1000 is not in your NON_RECOVERABLE_CLOSE_CODES set, the close event could potentially fall through to the reconnection logic. This means:
- Reconnection timers might be scheduled
- The component is already unmounted, so any callbacks would reference stale DOM/refs
- Resource leak and potential errors
Current Mitigation
I notice your unmount cleanup does call grResetState() at the end:
grResetState(); // Ensure global state is clean
clearConnectionTimeout();Key question: Does grResetState() properly cancel any pending reconnection timers? If yes, this concern is mitigated. If not, there's a potential issue.
Recommended Solutions
Option 1: Add a specific close reason for unmount to prevent reconnection logic from firing:
// Close with a reason that handleWebSocketClose recognizes as non-reconnectable
socketReference.current.close(1000, "component-unmount");Then update handleWebSocketClose to recognize it:
const userClosedTerminal =
event.reason === "user-closed-primary" ||
event.reason === "user-closed-secondary" ||
event.reason === "manual-reconnect" ||
event.reason === "component-unmount";Option 2: Explicitly cancel reconnection and remove event listeners before closing:
grCancelReconnect(); // Cancel any pending reconnect timers
if (socketReference.current) {
// Note: Since you use addEventListener, you'd need to remove listeners
// rather than set onclose = null
socketReference.current.close();
}
grResetState();The design intent (normal close to allow resume) is good, but you need to ensure the close handler doesn't trigger unwanted reconnection attempts after unmount. Let me know if you'd like me to search for the implementation of grResetState to verify it cancels timers properly!
There was a problem hiding this comment.
Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!
| socketReference.current.close(4001, "user-closed-panel"); | ||
| } | ||
| if ( | ||
| secondarySocketReference.current && | ||
| secondarySocketReference.current.readyState < WebSocket.CLOSING | ||
| ) { | ||
| secondarySocketReference.current.close(4001, "user-closed-panel"); |
There was a problem hiding this comment.
terminateSession() closes with reason "user-closed-panel", but handleWebSocketClose only treats "user-closed-primary"/"user-closed-secondary"/"manual-reconnect" as user-initiated. As a result, a 4001 close here will run the non-recoverable path and may briefly render a disconnect/error UI and trigger state updates just before unmount. Either include this reason in the user-initiated branch (and explicitly clear stored session IDs there) or adjust the close handling so explicit panel termination doesn’t surface as an error.
| socketReference.current.close(4001, "user-closed-panel"); | |
| } | |
| if ( | |
| secondarySocketReference.current && | |
| secondarySocketReference.current.readyState < WebSocket.CLOSING | |
| ) { | |
| secondarySocketReference.current.close(4001, "user-closed-panel"); | |
| socketReference.current.close(4001, "user-closed-primary"); | |
| } | |
| if ( | |
| secondarySocketReference.current && | |
| secondarySocketReference.current.readyState < WebSocket.CLOSING | |
| ) { | |
| secondarySocketReference.current.close(4001, "user-closed-secondary"); |
There was a problem hiding this comment.
@coderabbitai what do you think about above review comment, can you explain a bit more
There was a problem hiding this comment.
@sacOO7, let me explain the issue Copilot identified:
The Problem
When terminateSession() is called (typically during unmount), it closes the WebSocket with:
- Code:
4001 - Reason:
"user-closed-panel"
However, in the handleWebSocketClose function, there's a check for user-initiated closes:
const userClosedTerminal =
event.reason === "user-closed-primary" ||
event.reason === "user-closed-secondary" ||
event.reason === "manual-reconnect";
if (userClosedTerminal) {
// Exit early - no reconnection, no error UI
return;
}Since "user-closed-panel" is not in this list, the close event continues through the handler. Then it hits this check:
const NON_RECOVERABLE_CLOSE_CODES = new Set<number>([
4001, // Policy violation
// ...
]);
if (NON_RECOVERABLE_CLOSE_CODES.has(event.code)) {
// Shows error UI, sets manual reconnect prompt, etc.
}The Consequence
When the component unmounts and terminateSession() is called:
- WebSocket closes with code 4001 and reason "user-closed-panel"
handleWebSocketClosedoesn't recognize this as user-initiated- It enters the non-recoverable error path
- Shows error overlay/UI and triggers state updates
- All this happens right before the component unmounts (unnecessary work)
The Solution
Instead of changing the close reason (as Copilot suggested), I'd recommend adding "user-closed-panel" to the user-initiated check. This preserves the semantic clarity of the close reason while fixing the handling:
const userClosedTerminal =
event.reason === "user-closed-primary" ||
event.reason === "user-closed-secondary" ||
event.reason === "manual-reconnect" ||
event.reason === "user-closed-panel"; // Add this lineThis way:
- The server still receives code 4001 with reason "user-closed-panel" for immediate cleanup (as per your PR objectives)
- The client recognizes it as an intentional user action
- No error UI or unnecessary state updates during unmount
- The reason remains semantically meaningful ("user-closed-panel" is clearer than "user-closed-primary")
Would you like me to search for the exact location in the file where this change should be made?
There was a problem hiding this comment.
Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!
| const { unmount } = render( | ||
| <AblyCliTerminal | ||
| websocketUrl="wss://test.ably.com" | ||
| ablyApiKey="test-key" | ||
| />, |
There was a problem hiding this comment.
These new tests render AblyCliTerminal with ablyApiKey, but the component props interface in this package expects signedConfig + signature (and there is no ablyApiKey prop). This will fail type-checking and likely won’t exercise the real auth/connection path. Update the tests to use the existing renderTerminal helper/fixtures (DEFAULT_SIGNED_CONFIG / DEFAULT_SIGNATURE / TEST_WEBSOCKET_URL) so they compile and match the component’s supported API.
| await act(async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| }); |
There was a problem hiding this comment.
The tests rely on setTimeout(..., 50) to wait for the socket to open. Elsewhere in this file you use waitFor assertions on mockSocketInstance.readyState === WebSocket.OPEN, which is less timing/flakiness-prone. Consider switching these waits to waitFor to reduce intermittent failures in CI.
See FTF-198 This PR ensures the terminal component signals session closure correctly over WebSocket, while preserving session resume on navigation/reload.
Companion server PR: https://github.com/ably/cli-terminal-server/pull/105
Problem
The original implementation sent close code
4001on every component unmount. This told the server to immediately destroy the session — but unmount also fires on page navigation and reloads, which brokeresumeOnReloadbehavior.Solution
close()with no special code, so the server's grace period applies and the session remains resumable.terminateSession()— a new method exposed on theAblyCliTerminalHandleref — sendsclose(4001, "user-closed-panel")for immediate server cleanup. The parent should call this before unmounting when the user explicitly closes the panel.Changes
AblyCliTerminal.tsxclose(); addedterminateSession()on the imperative handle for explicit 4001 closeAblyCliTerminal.test.tsxterminateSession()sends 4001Usage
Note
Low Risk
Small, isolated change to WebSocket close semantics plus unit tests; main risk is behavioral expectations around when sessions should be terminated vs resumable.
Overview
Preserves
resumeOnReloadby changingAblyCliTerminalunmount cleanup to perform a normalWebSocket.close()(no4001), so the server can apply its resume grace period.Adds an imperative
terminateSession()method onAblyCliTerminalHandlethat explicitly closes primary/secondary sockets withclose(4001, "user-closed-panel")for immediate server-side session cleanup, and introduces tests covering unmount close behavior, skipping close when alreadyCLOSING, andterminateSession()sending4001.Written by Cursor Bugbot for commit 1caa265. This will update automatically on new commits. Configure here.