|
5 | 5 | import { sessionState, scriptMap } from "../lib/stores"; |
6 | 6 | import { VirtualConsole } from "../lib/virtual-console"; |
7 | 7 | import { attachMessageHandler } from "../lib/socket"; |
| 8 | + import { ReconnectionController } from "../lib/reconnection"; |
8 | 9 | import StatusPanel from "./StatusPanel.svelte"; |
9 | 10 | import SessionEndedCard from "./SessionEndedCard.svelte"; |
10 | 11 |
|
|
34 | 35 | $: inactive = $sessionState === "closed" || $sessionState === "readonly"; |
35 | 36 |
|
36 | 37 | let cleanClose = false; |
37 | | - let reconnectAttempts = 0; |
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 | | - ]; |
46 | | -
|
47 | | - let reconnectLine: ReturnType<VirtualConsole["addSpinnerLine"]> | null = null; |
48 | 38 | let virtualConsole: VirtualConsole; |
| 39 | + let reconnection: ReconnectionController; |
49 | 40 | let unsubscribeSessionState: (() => void) | null = null; |
50 | 41 |
|
51 | 42 | onMount(() => { |
52 | 43 | virtualConsole = new VirtualConsole(outputContainer); |
| 44 | + reconnection = new ReconnectionController( |
| 45 | + virtualConsole, |
| 46 | + () => socket ? new URL(socket.url).searchParams.get("session") : null, |
| 47 | + () => attemptReconnect(), |
| 48 | + ); |
53 | 49 |
|
54 | 50 | unsubscribeSessionState = sessionState.subscribe((state) => { |
55 | 51 | if (state === "closed" && !cleanClose && socket) { |
|
70 | 66 |
|
71 | 67 | onDestroy(() => { |
72 | 68 | unsubscribeSessionState?.(); |
73 | | - reconnectLine?.remove(); |
| 69 | + reconnection?.dispose(); |
74 | 70 | }); |
75 | 71 |
|
76 | 72 | function renderReadonlySession() { |
|
103 | 99 | attachMessageHandler(socket, virtualConsole); |
104 | 100 |
|
105 | 101 | socket.onopen = () => { |
106 | | - if (reconnectAttempts > 0) { |
107 | | - reconnectLine?.finalize("\u001b[32mReconnected.\u001b[0m"); |
108 | | - reconnectLine = null; |
109 | | - reconnectAttempts = 0; |
110 | | - } else { |
| 102 | + if (reconnection.handleOpen() === "initial") { |
111 | 103 | virtualConsole.addLines(["\u001b[32mConnection established. Waiting for session...\u001b[0m"]); |
112 | 104 | sessionState.set("provisioning"); |
113 | 105 | } |
|
117 | 109 |
|
118 | 110 | socket.onclose = async (event: CloseEvent) => { |
119 | 111 | if (cleanClose || event.code === 1000) { |
120 | | - reconnectLine?.remove(); |
121 | | - reconnectLine = null; |
| 112 | + reconnection.handleCleanClose(); |
122 | 113 | virtualConsole.addLines(["\u001b[90mConnection closed.\u001b[0m"]); |
123 | | - sessionState.set("closed"); |
124 | | - commandInput.disabled = true; |
125 | | - appendEndedCard(outputContainer); |
| 114 | + markSessionEnded(); |
126 | 115 | return; |
127 | 116 | } |
128 | 117 |
|
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) { |
| 118 | + const result = await reconnection.handleAbnormalClose(); |
| 119 | + if (result === "ended") { |
135 | 120 | 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 | | -
|
145 | | - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { |
146 | | - reconnectAttempts++; |
147 | | - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000); |
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 | | - } |
155 | | - setTimeout(() => attemptReconnect(), delay); |
156 | | - } else { |
157 | | - reconnectLine?.finalize("\u001b[31mCouldn't reconnect. The session may have ended.\u001b[0m"); |
158 | | - reconnectLine = null; |
159 | | - sessionState.set("closed"); |
160 | | - commandInput.disabled = true; |
161 | | - appendEndedCard(outputContainer); |
| 121 | + markSessionEnded(); |
162 | 122 | } |
163 | 123 | }; |
164 | 124 |
|
165 | 125 | 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; |
| 126 | + if (reconnection.isReconnecting) return; |
169 | 127 | console.error("WebSocket error:", err); |
170 | 128 | virtualConsole.addLines(["\u001b[31mWebSocket connection error.\u001b[0m"]); |
171 | 129 | }; |
172 | 130 | } |
173 | 131 |
|
| 132 | + function markSessionEnded() { |
| 133 | + sessionState.set("closed"); |
| 134 | + commandInput.disabled = true; |
| 135 | + appendEndedCard(outputContainer); |
| 136 | + } |
| 137 | +
|
174 | 138 | function attemptReconnect() { |
175 | 139 | if (!socket || cleanClose || $sessionState === "closed") return; |
176 | 140 | const wsUrl = socket.url; |
177 | 141 | socket = new WebSocket(wsUrl); |
178 | 142 | setupSocket(); |
179 | 143 | } |
180 | 144 |
|
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 | | -
|
209 | 145 | function endSession() { |
210 | 146 | if (socket?.readyState === WebSocket.OPEN) { |
211 | 147 | socket.send(JSON.stringify({ type: "CLOSE_SESSION" })); |
|
0 commit comments