diff --git a/packages/react-web-cli/src/AblyCliTerminal.test.tsx b/packages/react-web-cli/src/AblyCliTerminal.test.tsx index 9e462b0b..373ae918 100644 --- a/packages/react-web-cli/src/AblyCliTerminal.test.tsx +++ b/packages/react-web-cli/src/AblyCliTerminal.test.tsx @@ -2542,3 +2542,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(); }