Skip to content

Commit 6a25784

Browse files
committed
Shore up and polish reconnect + close reason logic
1 parent e107d41 commit 6a25784

5 files changed

Lines changed: 113 additions & 4 deletions

File tree

web/frontend/src/app.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ body {
1212
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
1313

1414
.console-output-line { white-space: pre-wrap; word-break: break-all; }
15+
.console-spinner-line { color: #fbbf24; }
16+
.console-spinner { display: inline-block; width: 1ch; }
1517

1618
.console-script-link {
1719
display: inline-flex;

web/frontend/src/components/Console.svelte

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,15 @@
3636
let cleanClose = false;
3737
let reconnectAttempts = 0;
3838
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+
];
3946
47+
let reconnectLine: ReturnType<VirtualConsole["addSpinnerLine"]> | null = null;
4048
let virtualConsole: VirtualConsole;
4149
let unsubscribeSessionState: (() => void) | null = null;
4250
@@ -62,6 +70,7 @@
6270
6371
onDestroy(() => {
6472
unsubscribeSessionState?.();
73+
reconnectLine?.remove();
6574
});
6675
6776
function renderReadonlySession() {
@@ -95,7 +104,8 @@
95104
96105
socket.onopen = () => {
97106
if (reconnectAttempts > 0) {
98-
virtualConsole.addLines(["\u001b[32mReconnected.\u001b[0m"]);
107+
reconnectLine?.finalize("\u001b[32mReconnected.\u001b[0m");
108+
reconnectLine = null;
99109
reconnectAttempts = 0;
100110
} else {
101111
virtualConsole.addLines(["\u001b[32mConnection established. Waiting for session...\u001b[0m"]);
@@ -105,29 +115,57 @@
105115
commandInput.focus();
106116
};
107117
108-
socket.onclose = (event: CloseEvent) => {
118+
socket.onclose = async (event: CloseEvent) => {
109119
if (cleanClose || event.code === 1000) {
120+
reconnectLine?.remove();
121+
reconnectLine = null;
110122
virtualConsole.addLines(["\u001b[90mConnection closed.\u001b[0m"]);
111123
sessionState.set("closed");
112124
commandInput.disabled = true;
113125
appendEndedCard(outputContainer);
114126
return;
115127
}
116128
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+
117145
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
118146
reconnectAttempts++;
119147
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+
}
121155
setTimeout(() => attemptReconnect(), delay);
122156
} 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;
124159
sessionState.set("closed");
125160
commandInput.disabled = true;
126161
appendEndedCard(outputContainer);
127162
}
128163
};
129164
130165
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;
131169
console.error("WebSocket error:", err);
132170
virtualConsole.addLines(["\u001b[31mWebSocket connection error.\u001b[0m"]);
133171
};
@@ -140,6 +178,34 @@
140178
setupSocket();
141179
}
142180
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+
143209
function endSession() {
144210
if (socket?.readyState === WebSocket.OPEN) {
145211
socket.send(JSON.stringify({ type: "CLOSE_SESSION" }));

web/frontend/src/lib/virtual-console.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,45 @@ export class VirtualConsole {
4242
}
4343
}
4444

45+
addSpinnerLine(initialText: string): { update: (text: string) => void; finalize: (text: string) => void; remove: () => void } {
46+
const wasAtBottom = this.isAtBottom;
47+
const lineDiv = document.createElement("div");
48+
lineDiv.className = "console-output-line console-spinner-line";
49+
50+
const spinnerSpan = document.createElement("span");
51+
spinnerSpan.className = "console-spinner";
52+
53+
const textSpan = document.createElement("span");
54+
textSpan.innerHTML = this.ansiUp.ansi_to_html(initialText);
55+
56+
lineDiv.append(spinnerSpan, " ", textSpan);
57+
this.container.appendChild(lineDiv);
58+
if (wasAtBottom) this.container.scrollTop = this.container.scrollHeight;
59+
60+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
61+
let frameIndex = 0;
62+
spinnerSpan.textContent = frames[0];
63+
const interval = setInterval(() => {
64+
frameIndex = (frameIndex + 1) % frames.length;
65+
spinnerSpan.textContent = frames[frameIndex];
66+
}, 80);
67+
68+
return {
69+
update: (text: string) => {
70+
textSpan.innerHTML = this.ansiUp.ansi_to_html(text);
71+
},
72+
finalize: (text: string) => {
73+
clearInterval(interval);
74+
spinnerSpan.remove();
75+
textSpan.innerHTML = this.ansiUp.ansi_to_html(text);
76+
},
77+
remove: () => {
78+
clearInterval(interval);
79+
lineDiv.remove();
80+
},
81+
};
82+
}
83+
4584
addScriptLink(name: string) {
4685
const wasAtBottom = this.isAtBottom;
4786
const el = document.createElement("div");

web/shared/protocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface SessionMetadata {
3838
containerTag: string;
3939
startedAt: number;
4040
endedAt?: number;
41+
closeReason?: string;
4142
}
4243

4344
export interface SessionTimerPayload {

web/src/session/base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ export class BaseSession extends Container<Env> {
422422
const endedAt = Date.now();
423423
if (this.sessionMetadata) {
424424
this.sessionMetadata.endedAt = endedAt;
425+
this.sessionMetadata.closeReason = reason;
425426
}
426427

427428
if (this.containerSocket) {

0 commit comments

Comments
 (0)