Skip to content

Commit 3857430

Browse files
committed
Refactor, simplify console.svelte
1 parent 6a25784 commit 3857430

6 files changed

Lines changed: 161 additions & 105 deletions

File tree

web/frontend/src/components/Console.svelte

Lines changed: 21 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { sessionState, scriptMap } from "../lib/stores";
66
import { VirtualConsole } from "../lib/virtual-console";
77
import { attachMessageHandler } from "../lib/socket";
8+
import { ReconnectionController } from "../lib/reconnection";
89
import StatusPanel from "./StatusPanel.svelte";
910
import SessionEndedCard from "./SessionEndedCard.svelte";
1011
@@ -34,22 +35,17 @@
3435
$: inactive = $sessionState === "closed" || $sessionState === "readonly";
3536
3637
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;
4838
let virtualConsole: VirtualConsole;
39+
let reconnection: ReconnectionController;
4940
let unsubscribeSessionState: (() => void) | null = null;
5041
5142
onMount(() => {
5243
virtualConsole = new VirtualConsole(outputContainer);
44+
reconnection = new ReconnectionController(
45+
virtualConsole,
46+
() => socket ? new URL(socket.url).searchParams.get("session") : null,
47+
() => attemptReconnect(),
48+
);
5349
5450
unsubscribeSessionState = sessionState.subscribe((state) => {
5551
if (state === "closed" && !cleanClose && socket) {
@@ -70,7 +66,7 @@
7066
7167
onDestroy(() => {
7268
unsubscribeSessionState?.();
73-
reconnectLine?.remove();
69+
reconnection?.dispose();
7470
});
7571
7672
function renderReadonlySession() {
@@ -103,11 +99,7 @@
10399
attachMessageHandler(socket, virtualConsole);
104100
105101
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") {
111103
virtualConsole.addLines(["\u001b[32mConnection established. Waiting for session...\u001b[0m"]);
112104
sessionState.set("provisioning");
113105
}
@@ -117,95 +109,39 @@
117109
118110
socket.onclose = async (event: CloseEvent) => {
119111
if (cleanClose || event.code === 1000) {
120-
reconnectLine?.remove();
121-
reconnectLine = null;
112+
reconnection.handleCleanClose();
122113
virtualConsole.addLines(["\u001b[90mConnection closed.\u001b[0m"]);
123-
sessionState.set("closed");
124-
commandInput.disabled = true;
125-
appendEndedCard(outputContainer);
114+
markSessionEnded();
126115
return;
127116
}
128117
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") {
135120
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();
162122
}
163123
};
164124
165125
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;
169127
console.error("WebSocket error:", err);
170128
virtualConsole.addLines(["\u001b[31mWebSocket connection error.\u001b[0m"]);
171129
};
172130
}
173131
132+
function markSessionEnded() {
133+
sessionState.set("closed");
134+
commandInput.disabled = true;
135+
appendEndedCard(outputContainer);
136+
}
137+
174138
function attemptReconnect() {
175139
if (!socket || cleanClose || $sessionState === "closed") return;
176140
const wsUrl = socket.url;
177141
socket = new WebSocket(wsUrl);
178142
setupSocket();
179143
}
180144
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-
209145
function endSession() {
210146
if (socket?.readyState === WebSocket.OPEN) {
211147
socket.send(JSON.stringify({ type: "CLOSE_SESSION" }));
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { CloseReason } from "@glua/shared";
2+
import type { VirtualConsole } from "./virtual-console";
3+
4+
const MAX_ATTEMPTS = 5;
5+
6+
const RETRY_MESSAGES = [
7+
"Connection lost. Trying to reconnect...",
8+
"still trying...",
9+
"hang tight, one more try...",
10+
"still no luck, trying again...",
11+
"last try...",
12+
];
13+
14+
export type AbnormalCloseResult = "ended" | "retrying";
15+
16+
export class ReconnectionController {
17+
private attempts = 0;
18+
private line: ReturnType<VirtualConsole["addSpinnerLine"]> | null = null;
19+
20+
constructor(
21+
private readonly virtualConsole: VirtualConsole,
22+
private readonly getSessionId: () => string | null,
23+
private readonly retry: () => void,
24+
) {}
25+
26+
get isReconnecting(): boolean {
27+
return this.attempts > 0;
28+
}
29+
30+
handleOpen(): "initial" | "reconnected" {
31+
if (this.attempts > 0) {
32+
this.line?.finalize("\u001b[32mReconnected.\u001b[0m");
33+
this.line = null;
34+
this.attempts = 0;
35+
return "reconnected";
36+
}
37+
return "initial";
38+
}
39+
40+
handleCleanClose(): void {
41+
this.line?.remove();
42+
this.line = null;
43+
}
44+
45+
async handleAbnormalClose(): Promise<AbnormalCloseResult> {
46+
const reason = await this.fetchCloseReason();
47+
if (reason !== null) {
48+
this.line?.remove();
49+
this.line = null;
50+
this.virtualConsole.addLines([messageForCloseReason(reason)]);
51+
return "ended";
52+
}
53+
54+
if (this.attempts < MAX_ATTEMPTS) {
55+
this.attempts++;
56+
const delay = Math.min(1000 * 2 ** (this.attempts - 1), 10000);
57+
const label = RETRY_MESSAGES[Math.min(this.attempts - 1, RETRY_MESSAGES.length - 1)];
58+
const text = `${label} \u001b[90m(${this.attempts}/${MAX_ATTEMPTS})\u001b[0m`;
59+
if (this.line) {
60+
this.line.update(text);
61+
} else {
62+
this.line = this.virtualConsole.addSpinnerLine(text);
63+
}
64+
setTimeout(() => this.retry(), delay);
65+
return "retrying";
66+
}
67+
68+
this.line?.finalize("\u001b[31mCouldn't reconnect. The session may have ended.\u001b[0m");
69+
this.line = null;
70+
return "ended";
71+
}
72+
73+
dispose(): void {
74+
this.line?.remove();
75+
this.line = null;
76+
}
77+
78+
private async fetchCloseReason(): Promise<string | null> {
79+
const sessionId = this.getSessionId();
80+
if (!sessionId) return null;
81+
try {
82+
const res = await fetch(`/api/session-status?session=${sessionId}`);
83+
if (!res.ok) return "";
84+
const data = await res.json();
85+
if (data?.status === "active") return null;
86+
return data?.metadata?.closeReason ?? "";
87+
} catch {
88+
return null;
89+
}
90+
}
91+
}
92+
93+
function messageForCloseReason(reason: string): string {
94+
const known = reason as CloseReason;
95+
switch (known) {
96+
case "timer_expired": return "\u001b[33mSession expired.\u001b[0m";
97+
case "deploy_rollout": return "\u001b[33mSession ended — we pushed an update.\u001b[0m";
98+
case "clean": return "\u001b[90mSession ended.\u001b[0m";
99+
case "agent_shutdown":
100+
case "container_stopped":
101+
case "container_error":
102+
case "container_start_failed":
103+
case "agent_ws_close":
104+
case "agent_ws_error":
105+
return "\u001b[31mSession ended unexpectedly.\u001b[0m";
106+
default: return "\u001b[90mSession ended.\u001b[0m";
107+
}
108+
}

web/shared/protocol.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,24 @@ export type AgentMessage =
3232
| { type: "METADATA"; payload: AgentMetadataPayload }
3333
| { type: "AGENT_SHUTDOWN" };
3434

35+
export type CloseReason =
36+
| "clean"
37+
| "timer_expired"
38+
| "agent_shutdown"
39+
| "container_stopped"
40+
| "container_error"
41+
| "container_start_failed"
42+
| "agent_ws_close"
43+
| "agent_ws_error"
44+
| "deploy_rollout";
45+
3546
export interface SessionMetadata {
3647
branch: string;
3748
gameVersion: string;
3849
containerTag: string;
3950
startedAt: number;
4051
endedAt?: number;
41-
closeReason?: string;
52+
closeReason?: CloseReason;
4253
}
4354

4455
export interface SessionTimerPayload {

web/src/observability/types.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
1-
export type CloseReason =
2-
| "clean"
3-
| "timer_expired"
4-
| "agent_shutdown"
5-
| "container_stopped"
6-
| "container_error"
7-
| "container_start_failed"
8-
| "agent_ws_close"
9-
| "agent_ws_error"
10-
| "deploy_rollout";
1+
import type { CloseReason } from "@glua/shared";
2+
3+
export type { CloseReason };
114

125
export interface RequestContext {
136
ip: string;

web/src/queue/manager.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export class SessionManager extends DurableObject<Env> {
171171
// CF always populates this header in production — getting here means
172172
// either an unusual proxy setup or a Cloudflare bug. Either way it
173173
// breaks our per-IP rate limiting, so we want to know about it
174-
this.ctx.waitUntil(
174+
this.notifyAsync(
175175
notify.warning(this.env, {
176176
title: "Missing CF-Connecting-IP",
177177
description: "A request reached SessionManager without a CF-Connecting-IP header — per-IP rate limiting is disabled for this session",
@@ -183,7 +183,7 @@ export class SessionManager extends DurableObject<Env> {
183183

184184
if (clientIP !== "unknown" && this.activeSessionCountForIP(clientIP) >= MAX_SESSIONS_PER_IP) {
185185
const obsContext = parseContext(request.headers.get(OBS_CONTEXT_HEADER));
186-
this.ctx.waitUntil(
186+
this.notifyAsync(
187187
notify.warning(this.env, {
188188
title: "IP rate limit hit",
189189
description: `Tried to open a **${sessionType}** session past the per-IP limit of **${MAX_SESSIONS_PER_IP}**.`,
@@ -208,7 +208,7 @@ export class SessionManager extends DurableObject<Env> {
208208

209209
const obsContext = parseContext(request.headers.get(OBS_CONTEXT_HEADER));
210210
if (obsContext) {
211-
this.ctx.waitUntil(
211+
this.notifyAsync(
212212
notify.queueEntered(this.env, {
213213
sessionType,
214214
position,
@@ -300,6 +300,10 @@ export class SessionManager extends DurableObject<Env> {
300300

301301
// ── Capacity tracking ──
302302

303+
private notifyAsync(promise: Promise<unknown>): void {
304+
this.ctx.waitUntil(promise);
305+
}
306+
303307
private activeSessionCountForIP(ip: string): number {
304308
let count = 0;
305309
for (const [sessionId, sessionIp] of this.sessionIPs) {

0 commit comments

Comments
 (0)