From 6d67b866d25ed6cf29d89c41e468924973f2ad12 Mon Sep 17 00:00:00 2001 From: Denis Sellu Date: Mon, 16 Feb 2026 14:49:29 +0100 Subject: [PATCH] fix: add terminateSession() for explicit close code 4001, keep unmount 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. --- .../src/AblyCliTerminal.test.tsx | 73 +++++++++++++++++++ .../react-web-cli/src/AblyCliTerminal.tsx | 21 +++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/react-web-cli/src/AblyCliTerminal.test.tsx b/packages/react-web-cli/src/AblyCliTerminal.test.tsx index 6ccbc0f3..accc4304 100644 --- a/packages/react-web-cli/src/AblyCliTerminal.test.tsx +++ b/packages/react-web-cli/src/AblyCliTerminal.test.tsx @@ -2557,3 +2557,76 @@ describe("AblyCliTerminal - Initial Command Execution", () => { expect(hasTestCmd).toBe(true); }, 15_000); }); + +describe("AblyCliTerminal - Unmount cleanup", () => { + test("closes socket normally on unmount (no special code for resume support)", async () => { + mockClose.mockClear(); + + const { unmount } = render( + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + expect(mockSocketInstance).toBeTruthy(); + expect(mockSocketInstance.readyState).toBe(WebSocket.OPEN); + + unmount(); + + // Should close without special code (allows grace period for resume) + expect(mockClose).toHaveBeenCalledWith(); + }); + + test("does not call close if socket already closing", async () => { + mockClose.mockClear(); + + const { unmount } = render( + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + mockSocketInstance.readyState = WebSocket.CLOSING; + + unmount(); + + expect(mockClose).not.toHaveBeenCalled(); + }); + + test("terminateSession() sends close code 4001 for immediate cleanup", async () => { + mockClose.mockClear(); + const terminalRef = React.createRef(); + + render( + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + expect(mockSocketInstance).toBeTruthy(); + expect(mockSocketInstance.readyState).toBe(WebSocket.OPEN); + + // Call terminateSession explicitly + act(() => { + terminalRef.current?.terminateSession(); + }); + + expect(mockClose).toHaveBeenCalledWith(4001, "user-closed-panel"); + }); +}); diff --git a/packages/react-web-cli/src/AblyCliTerminal.tsx b/packages/react-web-cli/src/AblyCliTerminal.tsx index 3edc5777..f8256035 100644 --- a/packages/react-web-cli/src/AblyCliTerminal.tsx +++ b/packages/react-web-cli/src/AblyCliTerminal.tsx @@ -142,6 +142,8 @@ export interface AblyCliTerminalHandle { setSplitPosition: (percent: number) => void; /** Read current split state. */ getSplitState: () => { isSplit: boolean; splitPosition: number }; + /** Terminate the session immediately. Call this before unmounting when user explicitly closes the panel. */ + terminateSession: () => void; } // Use shared debug logging @@ -340,6 +342,23 @@ const AblyCliTerminalInner = ( setSplitPosition(clamped); }, getSplitState: () => ({ isSplit, splitPosition }), + terminateSession: () => { + debugLog( + "[AblyCLITerminal] terminateSession called - closing with code 4001", + ); + if ( + socketReference.current && + socketReference.current.readyState < WebSocket.CLOSING + ) { + socketReference.current.close(4001, "user-closed-panel"); + } + if ( + secondarySocketReference.current && + secondarySocketReference.current.readyState < WebSocket.CLOSING + ) { + secondarySocketReference.current.close(4001, "user-closed-panel"); + } + }, }), [ enableSplitScreen, @@ -2382,7 +2401,7 @@ const AblyCliTerminalInner = ( socketReference.current && socketReference.current.readyState < WebSocket.CLOSING ) { - // close websocket + // Normal close (no 4001) so server grace period allows resume debugLog("[AblyCLITerminal] Closing WebSocket on unmount."); socketReference.current.close(); }