|
36 | 36 | let cleanClose = false; |
37 | 37 | let reconnectAttempts = 0; |
38 | 38 | const MAX_RECONNECT_ATTEMPTS = 5; |
| 39 | + const RECONNECT_MESSAGES = [ |
| 40 | + "Connection lost. Trying to reconnect...", |
| 41 | + "still trying...", |
| 42 | + "hang tight, one more try...", |
| 43 | + "still no luck, trying again...", |
| 44 | + "last try...", |
| 45 | + ]; |
39 | 46 |
|
| 47 | + let reconnectLine: ReturnType<VirtualConsole["addSpinnerLine"]> | null = null; |
40 | 48 | let virtualConsole: VirtualConsole; |
41 | 49 | let unsubscribeSessionState: (() => void) | null = null; |
42 | 50 |
|
|
62 | 70 |
|
63 | 71 | onDestroy(() => { |
64 | 72 | unsubscribeSessionState?.(); |
| 73 | + reconnectLine?.remove(); |
65 | 74 | }); |
66 | 75 |
|
67 | 76 | function renderReadonlySession() { |
|
95 | 104 |
|
96 | 105 | socket.onopen = () => { |
97 | 106 | if (reconnectAttempts > 0) { |
98 | | - virtualConsole.addLines(["\u001b[32mReconnected.\u001b[0m"]); |
| 107 | + reconnectLine?.finalize("\u001b[32mReconnected.\u001b[0m"); |
| 108 | + reconnectLine = null; |
99 | 109 | reconnectAttempts = 0; |
100 | 110 | } else { |
101 | 111 | virtualConsole.addLines(["\u001b[32mConnection established. Waiting for session...\u001b[0m"]); |
|
105 | 115 | commandInput.focus(); |
106 | 116 | }; |
107 | 117 |
|
108 | | - socket.onclose = (event: CloseEvent) => { |
| 118 | + socket.onclose = async (event: CloseEvent) => { |
109 | 119 | if (cleanClose || event.code === 1000) { |
| 120 | + reconnectLine?.remove(); |
| 121 | + reconnectLine = null; |
110 | 122 | virtualConsole.addLines(["\u001b[90mConnection closed.\u001b[0m"]); |
111 | 123 | sessionState.set("closed"); |
112 | 124 | commandInput.disabled = true; |
113 | 125 | appendEndedCard(outputContainer); |
114 | 126 | return; |
115 | 127 | } |
116 | 128 |
|
| 129 | + // A close with code != 1000 could be genuine network trouble (reconnect) |
| 130 | + // or the server having already ended the session (race between the |
| 131 | + // SESSION_CLOSED message and the WS close frame). Ask the server which |
| 132 | + // it is before committing to a reconnect storm |
| 133 | + const closeReason = await getServerSideCloseReason(); |
| 134 | + if (closeReason !== null) { |
| 135 | + cleanClose = true; |
| 136 | + reconnectLine?.remove(); |
| 137 | + reconnectLine = null; |
| 138 | + virtualConsole.addLines([messageForCloseReason(closeReason)]); |
| 139 | + sessionState.set("closed"); |
| 140 | + commandInput.disabled = true; |
| 141 | + appendEndedCard(outputContainer); |
| 142 | + return; |
| 143 | + } |
| 144 | +
|
117 | 145 | if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { |
118 | 146 | reconnectAttempts++; |
119 | 147 | const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000); |
120 | | - virtualConsole.addLines([`\u001b[33mConnection lost. Reconnecting in ${Math.round(delay / 1000)}s... (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})\u001b[0m`]); |
| 148 | + const label = RECONNECT_MESSAGES[Math.min(reconnectAttempts - 1, RECONNECT_MESSAGES.length - 1)]; |
| 149 | + const labelWithCount = `${label} \u001b[90m(${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})\u001b[0m`; |
| 150 | + if (!reconnectLine) { |
| 151 | + reconnectLine = virtualConsole.addSpinnerLine(labelWithCount); |
| 152 | + } else { |
| 153 | + reconnectLine.update(labelWithCount); |
| 154 | + } |
121 | 155 | setTimeout(() => attemptReconnect(), delay); |
122 | 156 | } else { |
123 | | - virtualConsole.addLines(["\u001b[31mConnection lost. Could not reconnect.\u001b[0m"]); |
| 157 | + reconnectLine?.finalize("\u001b[31mCouldn't reconnect. The session may have ended.\u001b[0m"); |
| 158 | + reconnectLine = null; |
124 | 159 | sessionState.set("closed"); |
125 | 160 | commandInput.disabled = true; |
126 | 161 | appendEndedCard(outputContainer); |
127 | 162 | } |
128 | 163 | }; |
129 | 164 |
|
130 | 165 | socket.onerror = (err) => { |
| 166 | + // Suppress the noisy per-attempt error during reconnect — the spinner line |
| 167 | + // already tells the user we're retrying |
| 168 | + if (reconnectAttempts > 0) return; |
131 | 169 | console.error("WebSocket error:", err); |
132 | 170 | virtualConsole.addLines(["\u001b[31mWebSocket connection error.\u001b[0m"]); |
133 | 171 | }; |
|
140 | 178 | setupSocket(); |
141 | 179 | } |
142 | 180 |
|
| 181 | + function messageForCloseReason(reason: string): string { |
| 182 | + switch (reason) { |
| 183 | + case "timer_expired": return "\u001b[33mSession expired.\u001b[0m"; |
| 184 | + case "deploy_rollout": return "\u001b[33mSession ended — we pushed an update.\u001b[0m"; |
| 185 | + case "clean": return "\u001b[90mSession ended.\u001b[0m"; |
| 186 | + case "agent_shutdown": |
| 187 | + case "container_stopped": |
| 188 | + case "container_error": |
| 189 | + return "\u001b[31mSession ended unexpectedly.\u001b[0m"; |
| 190 | + default: return "\u001b[90mSession ended.\u001b[0m"; |
| 191 | + } |
| 192 | + } |
| 193 | +
|
| 194 | + async function getServerSideCloseReason(): Promise<string | null> { |
| 195 | + if (!socket) return null; |
| 196 | + const sessionId = new URL(socket.url).searchParams.get("session"); |
| 197 | + if (!sessionId) return null; |
| 198 | + try { |
| 199 | + const res = await fetch(`/api/session-status?session=${sessionId}`); |
| 200 | + if (!res.ok) return ""; |
| 201 | + const data = await res.json(); |
| 202 | + if (data?.status === "active") return null; |
| 203 | + return data?.metadata?.closeReason ?? ""; |
| 204 | + } catch { |
| 205 | + return null; |
| 206 | + } |
| 207 | + } |
| 208 | +
|
143 | 209 | function endSession() { |
144 | 210 | if (socket?.readyState === WebSocket.OPEN) { |
145 | 211 | socket.send(JSON.stringify({ type: "CLOSE_SESSION" })); |
|
0 commit comments