Skip to content

Commit e7f738e

Browse files
committed
Fix session counter, add SystemNotice
1 parent 2ca5b03 commit e7f738e

8 files changed

Lines changed: 92 additions & 30 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script lang="ts">
2+
export let message: string;
3+
</script>
4+
5+
<div class="px-4 py-2">
6+
<div class="bg-indigo-900/25 border border-indigo-500/40 rounded-lg px-4 py-3 max-w-md flex items-start gap-3">
7+
<img src="/favicon.png" alt="" class="w-8 h-8 rounded shrink-0 mt-0.5" />
8+
<div>
9+
<p class="text-indigo-300 text-xs font-semibold">glua.dev</p>
10+
<p class="text-gray-200 text-sm mt-0.5">{message}</p>
11+
</div>
12+
</div>
13+
</div>

web/frontend/src/lib/socket.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export function attachMessageHandler(socket: WebSocket, virtualConsole: VirtualC
3636
case "SESSION_CLOSED":
3737
sessionState.set("closed");
3838
break;
39+
case "SYSTEM_NOTICE":
40+
virtualConsole.mountSystemNotice(msg.payload.message);
41+
break;
3942
case "CONTEXT_UPDATE":
4043
sessionMetadata.set(msg.payload);
4144
break;

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import AnsiUp from "ansi_up";
2+
import { mount } from "svelte";
23
import { get } from "svelte/store";
4+
import SystemNotice from "../components/SystemNotice.svelte";
35
import { scriptMap, viewingScript } from "./stores";
46

57
/** Scrollable, ANSI-rendered log output backed by a plain div. */
@@ -81,6 +83,18 @@ export class VirtualConsole {
8183
};
8284
}
8385

86+
mountSystemNotice(message: string) {
87+
const wasAtBottom = this.isAtBottom;
88+
const target = document.createElement("div");
89+
this.container.appendChild(target);
90+
mount(SystemNotice, { target, props: { message } });
91+
if (wasAtBottom) {
92+
requestAnimationFrame(() => {
93+
this.container.scrollTop = this.container.scrollHeight;
94+
});
95+
}
96+
}
97+
8498
addScriptLink(name: string) {
8599
const wasAtBottom = this.isAtBottom;
86100
const el = document.createElement("div");

web/scripts/deploy-prod.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { dirname, join } from "node:path";
88

99
const __dirname = dirname(fileURLToPath(import.meta.url));
1010
const GRACE_SECONDS = 15;
11-
const ALERT_MESSAGE = `\u001b[33m*** glua.dev is deploying an update in ${GRACE_SECONDS}s — your session will briefly disconnect. Start a new one if it doesn't come back. ***\u001b[0m`;
11+
const ALERT_MESSAGE = `⚠️ We're pushing an update in ${GRACE_SECONDS}s — your session will be closed, sorry about that 🥀 You'll still be able to view its logs afterward, and you can start a new session anytime!`;
1212

1313
function run(command, args) {
1414
return new Promise((resolve, reject) => {
@@ -21,6 +21,9 @@ function run(command, args) {
2121
});
2222
}
2323

24+
console.log(`[deploy-prod] building frontend…`);
25+
await run("npm", ["run", "build"]);
26+
2427
console.log(`[deploy-prod] broadcasting heads-up to active sessions…`);
2528
try {
2629
await run("node", [join(__dirname, "prod-alert.mjs"), ALERT_MESSAGE]);

web/shared/protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export type ServerMessage =
1212
// Starts or updates the session countdown bar
1313
| { type: "SESSION_TIMER"; payload: SessionTimerPayload }
1414
| { type: "SESSION_CLOSED" }
15+
// An out-of-band message from glua.dev itself (deploy notices, ops broadcasts)
16+
// rendered as a styled card rather than a raw log line
17+
| { type: "SYSTEM_NOTICE"; payload: { message: string } }
1518
// Branch/version info the agent reports on boot
1619
| { type: "CONTEXT_UPDATE"; payload: SessionMetadata }
1720
// A script the user just ran — surfaced inline in the console as a clickable link

web/src/queue/manager.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DurableObject } from "cloudflare:workers";
22
import type { SessionType } from "@glua/shared";
33
import { MAX_SESSIONS_PER_IP, VALID_SESSION_TYPES } from "@glua/shared";
4-
import { CAPACITY, QUEUE_TIMING } from "../constants";
4+
import { CAPACITY, QUEUE_TIMING, SESSION_TIMING } from "../constants";
55
import type { Env } from "../env";
66
import { type CapacitySnapshot, notify, OBS_CONTEXT_HEADER, parseContext } from "../observability";
77
import { hashIP } from "../utils";
@@ -15,7 +15,9 @@ import {
1515
stripResolvedPrefix,
1616
stripSessionPrefix,
1717
} from "./storage-keys";
18-
import type { QueueEntry, ResolvedTicket } from "./types";
18+
import type { ActiveSession, QueueEntry, ResolvedTicket } from "./types";
19+
20+
const STALE_SESSION_CUTOFF = SESSION_TIMING.hardLimit + 60_000;
1921

2022
/**
2123
* Global singleton that manages session allocation and the waiting queue
@@ -27,24 +29,25 @@ import type { QueueEntry, ResolvedTicket } from "./types";
2729
* Keyed as `idFromName("global-queue")` — there's exactly one of these
2830
*/
2931
export class SessionManager extends DurableObject<Env> {
30-
private activeSessions: Map<string, SessionType>;
31-
private sessionIPs: Map<string, string>;
32+
private activeSessions: Map<string, ActiveSession>;
3233
private waitingQueue: QueueEntry[];
3334
private resolvedTickets: Map<string, ResolvedTicket>;
3435

3536
constructor(ctx: DurableObjectState, env: Env) {
3637
super(ctx, env);
3738
this.activeSessions = new Map();
38-
this.sessionIPs = new Map();
3939
this.waitingQueue = [];
4040
this.resolvedTickets = new Map();
4141

4242
ctx.blockConcurrencyWhile(async () => {
43-
const stored = await ctx.storage.list<{ type: SessionType; ip: string }>({ prefix: SESSION_PREFIX });
43+
const stored = await ctx.storage.list<Partial<ActiveSession>>({ prefix: SESSION_PREFIX });
4444
for (const [key, value] of stored) {
4545
const sessionId = stripSessionPrefix(key);
46-
this.activeSessions.set(sessionId, value.type);
47-
this.sessionIPs.set(sessionId, value.ip);
46+
this.activeSessions.set(sessionId, {
47+
type: (value.type ?? "public") as SessionType,
48+
ip: value.ip ?? "unknown",
49+
createdAt: value.createdAt ?? 0,
50+
});
4851
}
4952

5053
const now = Date.now();
@@ -115,10 +118,11 @@ export class SessionManager extends DurableObject<Env> {
115118
return Response.json({ error: "Missing 'message' string in body" }, { status: 400 });
116119
}
117120

121+
this.pruneStaleSessions();
118122
const entries = Array.from(this.activeSessions.entries());
119123
const results = await Promise.allSettled(
120-
entries.map(async ([sessionId, type]) => {
121-
const binding = this.bindingForType(type);
124+
entries.map(async ([sessionId, entry]) => {
125+
const binding = this.bindingForType(entry.type);
122126
if (!binding) return;
123127
const stub = binding.get(binding.idFromName(sessionId));
124128
await stub.fetch("http://do/internal/broadcast", {
@@ -246,11 +250,11 @@ export class SessionManager extends DurableObject<Env> {
246250

247251
private async handleSessionClosed(request: Request): Promise<Response> {
248252
const { sessionId } = await request.json<{ sessionId: string }>();
249-
const closedType = this.activeSessions.get(sessionId);
253+
const closedEntry = this.activeSessions.get(sessionId);
250254
await this.removeSession(sessionId);
251255

252-
if (closedType) {
253-
const idx = this.waitingQueue.findIndex((w) => w.sessionType === closedType);
256+
if (closedEntry) {
257+
const idx = this.waitingQueue.findIndex((w) => w.sessionType === closedEntry.type);
254258
if (idx !== -1) {
255259
const nextInLine = this.waitingQueue.splice(idx, 1)[0];
256260
const newSessionId = crypto.randomUUID();
@@ -275,9 +279,11 @@ export class SessionManager extends DurableObject<Env> {
275279
return Response.json({ status: "not-found" }, { status: 404 });
276280
}
277281

278-
if (this.activeSessions.has(sessionId)) {
279-
const sessionType = this.activeSessions.get(sessionId)!;
280-
return Response.json({ status: "active", sessionType });
282+
this.pruneStaleSessions();
283+
284+
const active = this.activeSessions.get(sessionId);
285+
if (active) {
286+
return Response.json({ status: "active", sessionType: active.type });
281287
}
282288

283289
const logKey = `sessions/${sessionId}/logs.log`;
@@ -306,26 +312,28 @@ export class SessionManager extends DurableObject<Env> {
306312

307313
private activeSessionCountForIP(ip: string): number {
308314
let count = 0;
309-
for (const [sessionId, sessionIp] of this.sessionIPs) {
310-
if (sessionIp === ip && this.activeSessions.has(sessionId)) count++;
315+
for (const entry of this.activeSessions.values()) {
316+
if (entry.ip === ip) count++;
311317
}
312318
return count;
313319
}
314320

315321
private activeCountForType(type: string): number {
316322
let count = 0;
317-
for (const t of this.activeSessions.values()) {
318-
if (t === type) count++;
323+
for (const entry of this.activeSessions.values()) {
324+
if (entry.type === type) count++;
319325
}
320326
return count;
321327
}
322328

323329
private hasCapacity(type: SessionType): boolean {
330+
this.pruneStaleSessions();
324331
const max = CAPACITY.maxPerType[type] ?? 2;
325332
return this.activeCountForType(type) < max && this.activeSessions.size < CAPACITY.maxTotal;
326333
}
327334

328335
private snapshotFor(branch: string): CapacitySnapshot {
336+
this.pruneStaleSessions();
329337
return {
330338
branch,
331339
branchUsed: this.activeCountForType(branch),
@@ -336,17 +344,29 @@ export class SessionManager extends DurableObject<Env> {
336344
};
337345
}
338346

347+
private pruneStaleSessions(): void {
348+
const cutoff = Date.now() - STALE_SESSION_CUTOFF;
349+
const stale: string[] = [];
350+
for (const [sessionId, entry] of this.activeSessions) {
351+
if (entry.createdAt < cutoff) stale.push(sessionId);
352+
}
353+
if (stale.length === 0) return;
354+
for (const sessionId of stale) {
355+
this.activeSessions.delete(sessionId);
356+
}
357+
this.notifyAsync(this.ctx.storage.delete(stale.map(sessionKey)).then(() => undefined));
358+
}
359+
339360
// ── Storage helpers ──
340361

341362
private async persistSession(sessionId: string, type: SessionType, ip: string) {
342-
this.activeSessions.set(sessionId, type);
343-
this.sessionIPs.set(sessionId, ip);
344-
await this.ctx.storage.put(sessionKey(sessionId), { type, ip });
363+
const entry: ActiveSession = { type, ip, createdAt: Date.now() };
364+
this.activeSessions.set(sessionId, entry);
365+
await this.ctx.storage.put(sessionKey(sessionId), entry);
345366
}
346367

347368
private async removeSession(sessionId: string) {
348369
this.activeSessions.delete(sessionId);
349-
this.sessionIPs.delete(sessionId);
350370
await this.ctx.storage.delete(sessionKey(sessionId));
351371
}
352372

web/src/queue/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ export interface ResolvedTicket {
1212
sessionType: SessionType;
1313
createdAt: number;
1414
}
15+
16+
export interface ActiveSession {
17+
type: SessionType;
18+
ip: string;
19+
createdAt: number;
20+
}

web/src/session/base.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export class BaseSession extends Container<Env> {
185185
if (typeof body.message !== "string" || body.message.length === 0) {
186186
return new Response("Missing message", { status: 400 });
187187
}
188-
this.broadcast({ type: "LOGS", payload: [body.message] });
188+
this.broadcast({ type: "SYSTEM_NOTICE", payload: { message: body.message } });
189189
return new Response("ok");
190190
}
191191

@@ -278,10 +278,10 @@ export class BaseSession extends Container<Env> {
278278

279279
if (isDeployRollout) {
280280
this.broadcast({
281-
type: "LOGS",
282-
payload: [
283-
"\u001b[33m*** We just pushed an update to glua.dev. We can't hot-swap running sessions (yet), so yours had to be closed — sorry about that 🥀 Start a new one to pick up where you left off! ***\u001b[0m",
284-
],
281+
type: "SYSTEM_NOTICE",
282+
payload: {
283+
message: "We just pushed an update to glua.dev. We can't hot-swap running sessions (yet), so yours had to be closed — sorry about that 🥀 Start a new one to pick up where you left off!",
284+
},
285285
});
286286
await this.closeSession("deploy_rollout");
287287
return;

0 commit comments

Comments
 (0)