fix: refcount StatsCache keys, keep registrations across config change, and tidy GPU/usage render paths

This commit is contained in:
c4ch3c4d3
2026-08-14 13:04:56 -06:00
parent d04eb6b524
commit 21d2e45f9e
5 changed files with 75 additions and 48 deletions
+2 -5
View File
@@ -87,7 +87,7 @@ export class GpuGraph extends SingletonAction<GpuSettings> {
const combined = combineSeries(histories, metric); const combined = combineSeries(histories, metric);
value = combined.value; value = combined.value;
history = combined.history; history = combined.history;
gpuName = `GPU ${ids.join("+")}`; gpuName = ids.length > 0 ? `GPU ${ids.join("+")}` : "NO GPUS";
} else { } else {
const gpuId = state.settings.gpuId ?? "all"; const gpuId = state.settings.gpuId ?? "all";
value = poller?.getValue(gpuId, metric); value = poller?.getValue(gpuId, metric);
@@ -108,9 +108,6 @@ export class GpuGraph extends SingletonAction<GpuSettings> {
} }
function parseCombo(raw: string | undefined): string[] | undefined { function parseCombo(raw: string | undefined): string[] | undefined {
const ids = (raw ?? "") const ids = [...new Set((raw ?? "").split(",").map((s) => s.trim()).filter(Boolean))];
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return ids.length > 0 ? ids : undefined; return ids.length > 0 ? ids : undefined;
} }
+12 -15
View File
@@ -24,6 +24,7 @@ type InflightState = {
unwatchStats?: () => void; unwatchStats?: () => void;
history: number[]; history: number[];
sampler?: ReturnType<typeof setInterval>; sampler?: ReturnType<typeof setInterval>;
lastSvg?: string;
}; };
const HISTORY_LIMIT = 60; const HISTORY_LIMIT = 60;
@@ -106,39 +107,35 @@ export class InflightMonitor extends SingletonAction<InflightSettings> {
private render(state: InflightState): void { private render(state: InflightState): void {
const action = state.action; const action = state.action;
if (!action) return; if (!action) return;
let svg: string;
if (this.displayOf(state) === "usage") { if (this.displayOf(state) === "usage") {
const modelKey = this.modelKeyOf(state); const modelKey = this.modelKeyOf(state);
const offline = runtime.offline; svg = svgDataUrl(
void action.setImage(
svgDataUrl(
renderUsage({ renderUsage({
modelName: modelKey, modelName: modelKey,
stats: runtime.getStats(modelKey), stats: runtime.getStats(modelKey),
primaryStat: this.primaryStatOf(state), primaryStat: this.primaryStatOf(state),
offline, offline: runtime.offline,
}), }),
),
); );
return; } else {
}
const modelId = state.settings.modelId ?? ""; const modelId = state.settings.modelId ?? "";
const trackerState = modelId === "all" ? "ready" : runtime.tracker.state(modelId); const trackerState = modelId === "all" ? "ready" : runtime.tracker.state(modelId);
const count = modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId); const count = modelId === "all" ? runtime.tracker.total() : runtime.tracker.count(modelId);
const offline = runtime.offline && modelId.length > 0; svg = svgDataUrl(
const modelName = modelId === "" ? "unset" : modelId;
void action.setImage(
svgDataUrl(
renderInflight({ renderInflight({
modelName, modelName: modelId === "" ? "unset" : modelId,
state: trackerState, state: trackerState,
count, count,
offline, offline: runtime.offline && modelId.length > 0,
history: state.history, history: state.history,
}), }),
),
); );
} }
if (svg === state.lastSvg) return;
state.lastSvg = svg;
void action.setImage(svg);
}
private sample(state: InflightState): void { private sample(state: InflightState): void {
const modelId = state.settings.modelId ?? ""; const modelId = state.settings.modelId ?? "";
+2 -5
View File
@@ -12,7 +12,6 @@ class Runtime {
private feed?: EventFeed; private feed?: EventFeed;
private poller?: MetricsPoller; private poller?: MetricsPoller;
private statsCache?: StatsCache; private statsCache?: StatsCache;
private statsUnsub?: () => void;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
ensureConnections(cfg: LlamaSwapConfig): void { ensureConnections(cfg: LlamaSwapConfig): void {
@@ -22,9 +21,7 @@ class Runtime {
this.poller?.stop(); this.poller?.stop();
this.feed = undefined; this.feed = undefined;
this.poller = undefined; this.poller = undefined;
this.statsUnsub?.(); this.statsCache?.setConfig(cfg);
this.statsCache = undefined;
this.statsUnsub = undefined;
this.cfg = cfg; this.cfg = cfg;
} }
if (!this.feed) { if (!this.feed) {
@@ -46,7 +43,7 @@ class Runtime {
} }
if (!this.statsCache) { if (!this.statsCache) {
this.statsCache = new StatsCache(cfg); this.statsCache = new StatsCache(cfg);
this.statsUnsub = this.statsCache.onChange(() => this.emit()); this.statsCache.onChange(() => this.emit());
} }
} }
+21 -8
View File
@@ -7,7 +7,8 @@ const ACTIVITY_THROTTLE_MS = 2000;
export type FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise<UsageStats | null>; export type FetchFn = (cfg: LlamaSwapConfig, modelId: string) => Promise<UsageStats | null>;
export class StatsCache { export class StatsCache {
private keys = new Set<string>(); private refs = new Map<string, number>();
private refreshing = false;
private values = new Map<string, UsageStats | undefined>(); private values = new Map<string, UsageStats | undefined>();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private timer?: ReturnType<typeof setInterval>; private timer?: ReturnType<typeof setInterval>;
@@ -27,8 +28,7 @@ export class StatsCache {
} }
register(key: string): void { register(key: string): void {
if (this.keys.has(key)) return; this.refs.set(key, (this.refs.get(key) ?? 0) + 1);
this.keys.add(key);
if (!this.timer) { if (!this.timer) {
void this.refresh(); void this.refresh();
this.timer = setInterval(() => void this.refresh(), this.pollMs); this.timer = setInterval(() => void this.refresh(), this.pollMs);
@@ -36,11 +36,18 @@ export class StatsCache {
} }
unregister(key: string): void { unregister(key: string): void {
this.keys.delete(key); const count = (this.refs.get(key) ?? 0) - 1;
if (count <= 0) {
this.refs.delete(key);
this.values.delete(key); this.values.delete(key);
if (this.keys.size === 0 && this.timer) { } else {
clearInterval(this.timer); this.refs.set(key, count);
}
if (this.refs.size === 0) {
if (this.timer) clearInterval(this.timer);
this.timer = undefined; this.timer = undefined;
if (this.throttleTimer) clearTimeout(this.throttleTimer);
this.throttleTimer = undefined;
} }
} }
@@ -59,16 +66,22 @@ export class StatsCache {
} }
async refresh(): Promise<void> { async refresh(): Promise<void> {
this.lastRefresh = Date.now(); if (this.refreshing) return;
for (const key of this.keys) { this.refreshing = true;
try {
for (const key of this.refs.keys()) {
try { try {
const stats = await this.fetchFn(this.cfg, key); const stats = await this.fetchFn(this.cfg, key);
if (stats) this.values.set(key, stats); if (stats) this.values.set(key, stats);
} catch { } catch {
} }
} }
} finally {
this.refreshing = false;
this.lastRefresh = Date.now();
this.emit(); this.emit();
} }
}
onChange(listener: () => void): () => void { onChange(listener: () => void): () => void {
this.listeners.add(listener); this.listeners.add(listener);
+23
View File
@@ -83,3 +83,26 @@ test("fetch failure keeps the last-known value", async () => {
assert.equal(cache.get("a"), undefined); assert.equal(cache.get("a"), undefined);
cache.unregister("a"); cache.unregister("a");
}); });
test("ref-counted register: a sibling unregister keeps the key active", async () => {
const calls = { count: 0 };
const cache = new StatsCache(cfg, stubFetch({ totalRequests: 5, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }, calls), 10000, 30);
cache.register("all");
cache.register("all");
cache.unregister("all");
await flush();
assert.equal(cache.get("all")!.totalRequests, 5);
assert.equal(calls.count, 1);
cache.unregister("all");
assert.equal(cache.get("all"), undefined);
});
test("setConfig keeps registrations but clears cached values", async () => {
const cache = new StatsCache(cfg, async (_c, key) => ({ totalRequests: 5, totalInputTokens: 1, totalOutputTokens: 1, genP95: 1 }), 10000, 30);
cache.register("all");
await flush();
assert.equal(cache.get("all")!.totalRequests, 5);
cache.setConfig({ baseUrl: "http://new" });
assert.equal(cache.get("all"), undefined);
cache.unregister("all");
});