Skip to content

Commit 8f33346

Browse files
committed
fix(runtime): make stale disposers, aborted refresh owners, and forced shutdown lease-exact
The debug ring unsubscribe now verifies registration identity so a stale disposer cannot evict a replacement listener while its admission lease stays live. An aborted Anthropic refresh owner records durable stale evidence, so the replacement flight surfaces a retryable stale error instead of escalating to reauthentication. Forced shutdown releases every admitted turn exactly once, including leases that never bound a controller, and late binding after that release stays idempotent. The guardian, quota, login, MCP decode-boundary, and GUI qualification tests now prove behavior instead of grepping source.
1 parent dd6c60b commit 8f33346

11 files changed

Lines changed: 488 additions & 52 deletions

gui/tests/apikeys-workspace.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,18 @@ test("stale selected key falls back to overview when list refreshes without it",
195195

196196
await act(async () => { root.unmount(); });
197197
});
198+
199+
test("capped API-key history qualifies total requests and attribution date in the rendered detail", async () => {
200+
const { root, container, rerender } = await mountWorkspace({ historyTruncated: true });
201+
await act(async () => { keyButton(container, "alpha").click(); });
202+
203+
const labels = () => [...container.querySelectorAll("dt")].map(node => node.textContent);
204+
expect(labels()).toContain("Requests in available history");
205+
expect(labels()).toContain("Available attribution since");
206+
207+
await rerender({ historyTruncated: false });
208+
expect(labels()).toContain("Total attributed requests");
209+
expect(labels()).toContain("Attribution available since");
210+
211+
await act(async () => { root.unmount(); });
212+
});

gui/tests/usage-layout.test.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { expect, test } from "bun:test";
2+
import { Window } from "happy-dom";
3+
import { act, createElement } from "react";
4+
import { clearClientResourceStoresForTests } from "../src/client-resource";
5+
import { LanguageProvider } from "../src/i18n/provider";
6+
import Usage from "../src/pages/Usage";
27

38
test("Usage renders every section in one scrollable column with a sticky strip", async () => {
49
const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text();
@@ -67,11 +72,73 @@ test("usage workspace i18n keys exist in every locale", async () => {
6772
}
6873
});
6974

70-
test("Usage All becomes Available history and API-key lifetime totals are qualified when capped", async () => {
71-
const usage = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text();
72-
const keys = await Bun.file(new URL("../src/components/apikeys-workspace/ApiKeysWorkspace.tsx", import.meta.url)).text();
73-
expect(usage).toContain('t("usage.range.available")');
74-
expect(usage).toContain('data?.historyTruncated');
75-
expect(keys).toContain('api.attribution.totalRequestsAvailable');
76-
expect(keys).toContain('api.attribution.sinceAvailable');
75+
test("Usage renders Available history and a persistent qualification when history is capped", async () => {
76+
const globalKeys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
77+
const previous = Object.fromEntries(globalKeys.map(key => [key, Reflect.get(globalThis, key)]));
78+
const originalFetch = globalThis.fetch;
79+
const testWindow = new Window({ url: "http://localhost/" });
80+
Object.defineProperties(globalThis, {
81+
document: { configurable: true, value: testWindow.document },
82+
window: { configurable: true, value: testWindow },
83+
navigator: { configurable: true, value: testWindow.navigator },
84+
localStorage: { configurable: true, value: testWindow.localStorage },
85+
});
86+
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
87+
clearClientResourceStoresForTests();
88+
globalThis.fetch = (async () => Response.json({
89+
range: "30d",
90+
surface: "all",
91+
since: null,
92+
generatedAt: Date.now(),
93+
summary: {
94+
requests: 0,
95+
measuredRequests: 0,
96+
reportedRequests: 0,
97+
unreportedRequests: 0,
98+
unsupportedRequests: 0,
99+
estimatedRequests: 0,
100+
inputTokens: 0,
101+
outputTokens: 0,
102+
cachedInputTokens: 0,
103+
reasoningOutputTokens: 0,
104+
totalTokens: 0,
105+
coverageRatio: 1,
106+
},
107+
days: [],
108+
models: [],
109+
providers: [],
110+
historyTruncated: true,
111+
truncatedPrefixBytes: 1,
112+
entriesTruncated: false,
113+
entriesDropped: 0,
114+
})) as typeof fetch;
115+
116+
const container = document.createElement("div");
117+
document.body.append(container);
118+
const { createRoot } = await import("react-dom/client");
119+
const root = createRoot(container);
120+
try {
121+
await act(async () => {
122+
root.render(createElement(LanguageProvider, null, createElement(Usage, { apiBase: "http://usage-qualification-test" })));
123+
});
124+
const deadline = Date.now() + 1_000;
125+
while (!(container.textContent ?? "").includes("Totals cover available history only")) {
126+
if (Date.now() >= deadline) throw new Error("Usage qualification did not render");
127+
await act(async () => {
128+
await new Promise<void>(resolve => testWindow.setTimeout(resolve, 10));
129+
});
130+
}
131+
132+
expect(container.querySelector('button[aria-label="Available history"]')).not.toBeNull();
133+
expect(container.textContent).toContain("Totals cover available history only because older usage was not loaded.");
134+
} finally {
135+
await act(async () => { root.unmount(); });
136+
container.remove();
137+
globalThis.fetch = originalFetch;
138+
clearClientResourceStoresForTests();
139+
testWindow.close();
140+
for (const key of globalKeys) {
141+
Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] });
142+
}
143+
}
77144
});

src/lib/debug-log-buffer.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const MAX_LINES = 2_000;
1111
const MAX_DEBUG_SUBSCRIBERS = 64;
1212
const MAX_DEBUG_LINE_BYTES = 16 * 1024;
1313
const buffer: DebugLogEntry[] = [];
14-
const listeners = new Map<(entry: DebugLogEntry) => void, AdmissionLease>();
14+
interface DebugSubscriberRegistration { lease: AdmissionLease }
15+
const listeners = new Map<(entry: DebugLogEntry) => void, DebugSubscriberRegistration>();
1516
const subscriberGate = createAdmissionGate("debug_subscribers", MAX_DEBUG_SUBSCRIBERS);
1617
let nextSeq = 1;
1718
let bufferBytes = 0;
@@ -44,17 +45,20 @@ export function getDebugLogEntries(options?: { after?: number; limit?: number })
4445
}
4546

4647
export function subscribeDebugLogEntries(listener: (entry: DebugLogEntry) => void): () => void {
47-
const existing = listeners.get(listener);
48-
if (existing) return () => {
49-
if (listeners.delete(listener)) existing.release();
50-
else existing.release();
51-
};
52-
const lease = subscriberGate.tryAcquire();
53-
if (!lease) throw new ResourceAdmissionError("debug_subscribers", MAX_DEBUG_SUBSCRIBERS);
54-
listeners.set(listener, lease);
48+
let registration = listeners.get(listener);
49+
if (!registration) {
50+
const lease = subscriberGate.tryAcquire();
51+
if (!lease) throw new ResourceAdmissionError("debug_subscribers", MAX_DEBUG_SUBSCRIBERS);
52+
registration = { lease };
53+
listeners.set(listener, registration);
54+
}
55+
let disposed = false;
5556
return () => {
56-
if (listeners.delete(listener)) lease.release();
57-
else lease.release();
57+
if (disposed) return;
58+
disposed = true;
59+
if (listeners.get(listener) !== registration) return;
60+
listeners.delete(listener);
61+
registration.lease.release();
5862
};
5963
}
6064

@@ -70,7 +74,7 @@ export function evictOldestDebugEntryForBudget(): number {
7074
export function resetDebugLogBufferForTests(): void {
7175
buffer.length = 0;
7276
bufferBytes = 0;
73-
for (const lease of listeners.values()) lease.release();
77+
for (const registration of listeners.values()) registration.lease.release();
7478
listeners.clear();
7579
nextSeq = 1;
7680
}

src/oauth/index.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
44
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
55
import { maskEmail } from "../lib/privacy";
66
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
7-
import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
7+
import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent } from "./store";
88
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
99
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
1010
import { loginKimi, refreshKimiToken } from "./kimi";
@@ -428,15 +428,16 @@ export async function refreshAnthropicAccountWithLock(
428428
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
429429
return disk.access;
430430
}
431-
if (
432-
deps.replacedStaleFlight
433-
&& deps.replacedStaleFlight.dispatched === false
434-
&& !pendingIntent?.uncertain
435-
&& pendingIntent?.generation === generation
436-
&& pendingIntent.flightId === deps.replacedStaleFlight.flightId
437-
) {
438-
clearOAuthRefreshIntent(provider, accountId, generation);
439-
pendingIntent = undefined;
431+
if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
432+
if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
433+
if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
434+
if (deps.replacedStaleFlight.dispatched) {
435+
markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId);
436+
throw new OAuthTokenRefreshStaleError();
437+
}
438+
clearOAuthRefreshIntent(provider, accountId, generation);
439+
pendingIntent = undefined;
440+
}
440441
}
441442
if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
442443
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);

src/oauth/store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function getAuthRefreshIntentLockPath(provider: string, accountId: string
5858
export function getAuthRefreshIntentPath(provider: string, accountId: string): string {
5959
return `${getAuthRefreshIntentLockPath(provider, accountId)}.json`;
6060
}
61-
export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; flightId?: string; uncertain?: true }
61+
export interface OAuthRefreshIntent { version: 1; provider: string; accountId: string; generation: string; createdAt: number; flightId?: string; staleOwner?: true; uncertain?: true }
6262
function parseOAuthRefreshIntent(
6363
provider: string,
6464
accountId: string,
@@ -72,6 +72,7 @@ function parseOAuthRefreshIntent(
7272
|| typeof value.generation !== "string"
7373
|| typeof value.createdAt !== "number"
7474
|| (value.flightId !== undefined && typeof value.flightId !== "string")
75+
|| (value.staleOwner !== undefined && value.staleOwner !== true)
7576
) {
7677
return { version: 1, provider, accountId, generation: "", createdAt: 0, uncertain: true };
7778
}
@@ -107,6 +108,12 @@ export function writeOAuthRefreshIntent(provider: string, accountId: string, gen
107108
const intent: OAuthRefreshIntent = { version: 1, provider, accountId, generation, createdAt, ...(flightId ? { flightId } : {}) };
108109
atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify(intent)}\n`);
109110
}
111+
export function markOAuthRefreshIntentStaleOwner(provider: string, accountId: string, generation: string, flightId: string): boolean {
112+
const current = readOAuthRefreshIntent(provider, accountId);
113+
if (current?.uncertain || current?.generation !== generation || current.flightId !== flightId) return false;
114+
atomicWriteFile(getAuthRefreshIntentPath(provider, accountId), `${JSON.stringify({ ...current, staleOwner: true })}\n`);
115+
return true;
116+
}
110117
export function clearOAuthRefreshIntent(provider: string, accountId: string, generation: string): boolean {
111118
const current = readOAuthRefreshIntent(provider, accountId);
112119
if (!current || current.generation !== generation) return false;

src/server/lifecycle.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,11 @@ export async function drainAndShutdown(
154154
const s = server ?? _serverRef;
155155
draining = true;
156156
const deadline = Date.now() + timeoutMs;
157-
while (activeTurns.size > 0 && Date.now() < deadline) {
157+
while (admittedTurns.size > 0 && Date.now() < deadline) {
158158
await Bun.sleep(100);
159159
}
160-
if (activeTurns.size > 0) {
161-
console.warn(`⚠️ Aborting ${activeTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
160+
if (admittedTurns.size > 0) {
161+
console.warn(`⚠️ Aborting ${admittedTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
162162
abortAndReleaseAllTurns(new Error("server shutdown"));
163163
}
164164
// Debounced replay-state snapshot may still be pending; flush so the last completed turn's

0 commit comments

Comments
 (0)