@@ -206,6 +206,18 @@ const BASE_NON_RECOVERABLE_CLOSE_CODES = [
206206// never confused.
207207const INACTIVITY_PAUSE_CLOSE_CODE = 4900 ;
208208
209+ // Private recoverable close code used when a socket opens but the server never
210+ // sends its `hello`/`connected` within AWAIT_HELLO_TIMEOUT_MS. Closing it as a
211+ // (recoverable, not in NON_RECOVERABLE) failure lets the reconnect scheduler
212+ // retry rather than leaving the UI stuck on "Connecting"/"Reconnecting" forever.
213+ const AWAIT_HELLO_CLOSE_CODE = 4901 ;
214+
215+ // How long to wait, after a socket has *opened*, for the server's first
216+ // `hello`/`status: connected` before treating the attempt as failed. The 30s
217+ // connection timeout only covers the pre-open CONNECTING phase, so without this
218+ // a silent-but-accepting server hangs the spinner indefinitely (DX-1379).
219+ const AWAIT_HELLO_TIMEOUT_MS = 12_000 ;
220+
209221const AblyCliTerminalInner = (
210222 {
211223 websocketUrl,
@@ -685,9 +697,14 @@ const AblyCliTerminalInner = (
685697 ( status : ConnectionStatus ) => {
686698 // updateConnectionStatusAndExpose debug removed
687699 // A successful connection clears any in-flight inactivity-resume guard so
688- // a later unrelated disconnect isn't misread as a failed resume.
700+ // a later unrelated disconnect isn't misread as a failed resume, and the
701+ // "awaiting hello" timeout (the handshake completed).
689702 if ( status === "connected" ) {
690703 resumeAttemptReference . current = false ;
704+ if ( awaitHelloTimerReference . current ) {
705+ clearTimeout ( awaitHelloTimerReference . current ) ;
706+ awaitHelloTimerReference . current = null ;
707+ }
691708 }
692709 setComponentConnectionStatusState ( status ) ;
693710 // (window as any).componentConnectionStatusForTest = status; // Keep for direct inspection if needed, but primary is below
@@ -761,6 +778,26 @@ const AblyCliTerminalInner = (
761778 }
762779 } , [ ] ) ;
763780
781+ // "Awaiting hello" timeout: armed once a socket opens, cleared on
782+ // connected/close. Guards against a server that accepts the socket but never
783+ // completes the handshake.
784+ const awaitHelloTimerReference = useRef < NodeJS . Timeout | null > ( null ) ;
785+ const clearAwaitHelloTimer = useCallback ( ( ) => {
786+ if ( awaitHelloTimerReference . current ) {
787+ clearTimeout ( awaitHelloTimerReference . current ) ;
788+ awaitHelloTimerReference . current = null ;
789+ }
790+ } , [ ] ) ;
791+
792+ // Same guard for the split-screen secondary socket (its own timer).
793+ const secondaryAwaitHelloTimerReference = useRef < NodeJS . Timeout | null > ( null ) ;
794+ const clearSecondaryAwaitHelloTimer = useCallback ( ( ) => {
795+ if ( secondaryAwaitHelloTimerReference . current ) {
796+ clearTimeout ( secondaryAwaitHelloTimerReference . current ) ;
797+ secondaryAwaitHelloTimerReference . current = null ;
798+ }
799+ } , [ ] ) ;
800+
764801 const clearInstallInstructionsTimer = useCallback ( ( ) => {
765802 if ( installInstructionsTimerReference . current ) {
766803 clearTimeout ( installInstructionsTimerReference . current ) ;
@@ -1577,6 +1614,24 @@ const AblyCliTerminalInner = (
15771614 sock . send ( JSON . stringify ( payload ) ) ;
15781615 }
15791616
1617+ // Arm the "awaiting hello" timeout: the socket is open, but until the
1618+ // server sends its hello/connected we have no session. If it never
1619+ // arrives, force-close this socket as a recoverable failure so the
1620+ // reconnect scheduler retries instead of the UI hanging on the spinner.
1621+ clearAwaitHelloTimer ( ) ;
1622+ awaitHelloTimerReference . current = setTimeout ( ( ) => {
1623+ if ( connectionStatusReference . current !== "connected" ) {
1624+ debugLog (
1625+ `⚠️ DIAGNOSTIC: No hello within ${ AWAIT_HELLO_TIMEOUT_MS } ms of open - closing socket to retry` ,
1626+ ) ;
1627+ try {
1628+ sock . close ( AWAIT_HELLO_CLOSE_CODE , "awaiting-hello-timeout" ) ;
1629+ } catch {
1630+ /* ignore */
1631+ }
1632+ }
1633+ } , AWAIT_HELLO_TIMEOUT_MS ) ;
1634+
15801635 // Set up initial command to be sent when prompt is detected
15811636 // Skip initial command if we're resuming an existing session
15821637 if ( initialCommand && ! sessionId ) {
@@ -1600,6 +1655,7 @@ const AblyCliTerminalInner = (
16001655 clearPtyBuffer ,
16011656 sessionId ,
16021657 clearConnectionTimeout ,
1658+ clearAwaitHelloTimer ,
16031659 refreshAuth ,
16041660 ] ,
16051661 ) ;
@@ -1844,6 +1900,7 @@ const AblyCliTerminalInner = (
18441900 `[AblyCLITerminal] WebSocket closed. Code: ${ event . code } , Reason: ${ event . reason } , Clean: ${ event . wasClean } ` ,
18451901 ) ;
18461902 clearConnectionTimeout ( ) ; // Clear timeout on close
1903+ clearAwaitHelloTimer ( ) ;
18471904 clearTerminalBoxOnly ( ) ;
18481905 updateSessionActive ( false ) ;
18491906
@@ -2070,6 +2127,7 @@ const AblyCliTerminalInner = (
20702127 resumeOnReload ,
20712128 sessionId ,
20722129 clearConnectionTimeout ,
2130+ clearAwaitHelloTimer ,
20732131 clearPtyBuffer ,
20742132 ] ,
20752133 ) ;
@@ -2796,6 +2854,11 @@ const AblyCliTerminalInner = (
27962854 ] ) ;
27972855
27982856 useEffect ( ( ) => ( ) => clearInactivityTimer ( ) , [ clearInactivityTimer ] ) ;
2857+ useEffect ( ( ) => ( ) => clearAwaitHelloTimer ( ) , [ clearAwaitHelloTimer ] ) ;
2858+ useEffect (
2859+ ( ) => ( ) => clearSecondaryAwaitHelloTimer ( ) ,
2860+ [ clearSecondaryAwaitHelloTimer ] ,
2861+ ) ;
27992862
28002863 // Cleanup install instructions timer on unmount
28012864 useEffect (
@@ -3118,6 +3181,19 @@ const AblyCliTerminalInner = (
31183181 newSocket . send ( JSON . stringify ( payload ) ) ;
31193182 }
31203183
3184+ // Bound the wait for the server's hello on the secondary socket too, so
3185+ // a silent-but-accepting server can't hang the secondary pane's spinner.
3186+ clearSecondaryAwaitHelloTimer ( ) ;
3187+ secondaryAwaitHelloTimerReference . current = setTimeout ( ( ) => {
3188+ if ( secondaryConnectionStatusReference . current !== "connected" ) {
3189+ try {
3190+ newSocket . close ( AWAIT_HELLO_CLOSE_CODE , "awaiting-hello-timeout" ) ;
3191+ } catch {
3192+ /* ignore */
3193+ }
3194+ }
3195+ } , AWAIT_HELLO_TIMEOUT_MS ) ;
3196+
31213197 // Set up initial command to be sent when prompt is detected
31223198 // Skip initial command if we're resuming an existing session
31233199 if ( initialCommand && ! secondarySessionId ) {
@@ -3346,6 +3422,7 @@ const AblyCliTerminalInner = (
33463422 debugLog (
33473423 `[AblyCLITerminal] [Secondary] WebSocket closed. Code: ${ event . code } , Reason: ${ event . reason } ` ,
33483424 ) ;
3425+ clearSecondaryAwaitHelloTimer ( ) ;
33493426 setIsSecondarySessionActive ( false ) ;
33503427 updateSecondaryConnectionStatus ( "disconnected" ) ;
33513428
@@ -3740,6 +3817,10 @@ const AblyCliTerminalInner = (
37403817 // Update internal state for the secondary terminal
37413818 setSecondaryConnectionStatus ( status ) ;
37423819 secondaryConnectionStatusReference . current = status ;
3820+ if ( status === "connected" && secondaryAwaitHelloTimerReference . current ) {
3821+ clearTimeout ( secondaryAwaitHelloTimerReference . current ) ;
3822+ secondaryAwaitHelloTimerReference . current = null ;
3823+ }
37433824
37443825 // We intentionally don't call onConnectionStatusChange here
37453826 // as per requirements - only the primary terminal status should be reported
0 commit comments