Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,11 @@ cd docs-site && bun run build
as a server follow-up candidate in 000 and in the PR.
- **A8 In-flight terminal replies win.** The hook (and finalizer) processes a terminal reply from a status
request already in flight even after Cancel; a later 404 still takes the neutral path (A3). A1's promise
becomes: success is shown only after a terminal `done` status reply is observed.
becomes: success is shown only after a terminal `done` status reply is observed. The handoff owns one
parsed status-read operation rather than cloned `Response` bodies. Its 45 s budget covers fetch, body EOF
and JSON parsing; the finalizer also cancels that reader when the flow-wide deadline wins and never waits
a full retry interval beyond the deadline. This preserves the already-sent terminal reply without letting
a stalled body retain the module-scoped singleflight entry indefinitely (#6021).
- **A9 Settle split at the guard.** Two helpers: `reloadAccountsAfterLogin(provider)` (awaited
`fetchAccountSets`) and `refreshDerivedAfterLogin()` (`fetchConfig`, `fetchProviderQuotas(true)`,
`bumpModelsRefresh`). The existing loop keeps its generation/mounted guard **between** them, keeps its
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redir
from the live CLI store, or when an existing primary CLI database has no recognized token row.
Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import
selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected.
The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review.
The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success.
The account list marks Kiro accounts excluded from automatic selection with a reason, when available.

## 3. API-key catalog
Expand Down
41 changes: 27 additions & 14 deletions gui/src/components/use-kiro-device-login.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { afterOAuthCancellation } from "../oauth-cancellation-barrier";
import { finalizeKiroDeviceFlow, observeKiroDeviceFinal, type KiroFinalOutcome } from "../kiro-device-login-finalizer";
import {
finalizeKiroDeviceFlow, observeKiroDeviceFinal, readKiroDeviceStatus,
type KiroFinalOutcome, type KiroStatusRead,
} from "../kiro-device-login-finalizer";
import { parseKiroDeviceView, type KiroDeviceMethod, type KiroDeviceView } from "../kiro-device-login-helpers";

type Phase = "idle" | "starting" | "pending" | "done" | "expired" | "failed" | "cancelled" | "ended";
export type KiroLoginState = { phase: Phase; view?: KiroDeviceView; error?: "start" | "network" | "invalid" };
type Session = { closed: boolean; view?: KiroDeviceView; inFlight?: Promise<Response | null>;
waitController?: AbortController; terminal?: KiroFinalOutcome };
type Session = { closed: boolean; closedController: AbortController; view?: KiroDeviceView;
inFlight?: KiroStatusRead; waitController?: AbortController; terminal?: KiroFinalOutcome };
const wait = (ms: number, signal: AbortSignal) => new Promise<void>(resolve => {
if (signal.aborted) { resolve(); return; }
const timer = setTimeout(() => { signal.removeEventListener("abort", stop); resolve(); }, ms);
Expand All @@ -16,7 +19,19 @@ const wait = (ms: number, signal: AbortSignal) => new Promise<void>(resolve => {
const CLOSED = Symbol("closed");
/** The awaited value, or CLOSED when the session closed while it was pending (a late reply belongs to the finalizer). */
const unlessClosed = <T,>(session: Session, value: Promise<T>): Promise<T | typeof CLOSED> =>
value.then(result => (session.closed ? CLOSED : result));
new Promise(resolve => {
if (session.closed) { resolve(CLOSED); return; }
let done = false;
const settle = (result: T | typeof CLOSED) => {
if (done) return;
done = true;
session.closedController.signal.removeEventListener("abort", stop);
resolve(result);
};
const stop = () => settle(CLOSED);
session.closedController.signal.addEventListener("abort", stop, { once: true });
void value.then(result => settle(session.closed ? CLOSED : result), () => settle(CLOSED));
});

export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: string, outcome: KiroFinalOutcome) => void,
pollDelay: (ms: number, signal: AbortSignal) => Promise<void> = wait) {
Expand Down Expand Up @@ -44,6 +59,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin
const session = sessionRef.current;
if (!session || session.closed) return;
session.closed = true;
session.closedController.abort();
sessionRef.current = null;
session.waitController?.abort();
if (mountedRef.current) setState({ phase: "cancelled" });
Expand All @@ -64,7 +80,7 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin

const start = useCallback(async (method: KiroDeviceMethod) => {
if (sessionRef.current) return;
const session: Session = { closed: false };
const session: Session = { closed: false, closedController: new AbortController() };
sessionRef.current = session;
setState({ phase: "starting" });
let response: Response | undefined;
Expand Down Expand Up @@ -114,21 +130,18 @@ export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: strin
break;
}
const flowId = view.flowId;
const request = fetch(`${apiBase}/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(flowId)}`).catch(() => null);
session.inFlight = request.then(response => response?.clone() ?? null);
const status = await unlessClosed(session, request);
const request = readKiroDeviceStatus(apiBase, flowId);
session.inFlight = request;
const status = await unlessClosed(session, request.result);
if (status === CLOSED) break;
if (status?.status === 404) {
session.inFlight = undefined;
session.inFlight = undefined;
if (status.kind === "missing") {
session.terminal = "ended";
settledRef.current?.("kiro", "ended");
setState({ phase: "ended", view: session.view });
break;
}
const body = status?.ok ? await unlessClosed(session, status.json().catch(() => null)) : null;
if (body === CLOSED) break;
const next = body === null ? null : parseKiroDeviceView(body);
session.inFlight = undefined;
const next = status.kind === "view" ? status.view : null;
if (!next || next.flowId !== flowId) continue;
session.view = next;
if (next.state === "pending") { setState({ phase: "pending", view: next }); continue; }
Expand Down
104 changes: 84 additions & 20 deletions gui/src/kiro-device-login-finalizer.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,84 @@
import { parseKiroDeviceView, type KiroDeviceView } from "./kiro-device-login-helpers";

export type KiroFinalOutcome = "added" | "ended" | "failed";
export type KiroStatusResult =
| { kind: "view"; view: KiroDeviceView }
| { kind: "missing" }
| { kind: "retry" };
export type KiroStatusRead = { result: Promise<KiroStatusResult>; cancel: () => void };
type Listener = (outcome: KiroFinalOutcome) => void;
const active = new Map<string, Promise<KiroFinalOutcome>>();
const terminal = new Map<string, KiroFinalOutcome>();
const listeners = new Map<string, Set<Listener>>();
const keyFor = (apiBase: string, flowId: string) => JSON.stringify([apiBase, flowId]);
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
async function boundedRead(read: Promise<Response | null>, ms: number): Promise<Response | null> {
const MAX_STATUS_BODY_BYTES = 64 * 1024;

function cancelBody(response: Response): void {
try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
}

/** Own fetch, body EOF and parsing as one cancellable operation; headers alone are not completion. */
export function readKiroDeviceStatus(apiBase: string, flowId: string, timeoutMs = 45_000): KiroStatusRead {
const controller = new AbortController();
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let settled = false;
let finish!: (result: KiroStatusResult) => void;
const result = new Promise<KiroStatusResult>(resolve => { finish = resolve; });
const settle = (value: KiroStatusResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
finish(value);
};
const cancel = () => {
if (settled) return;
controller.abort();
try { void reader?.cancel().catch(() => {}); } catch { /* best effort */ }
// Transport abort and stream cancellation are cooperative. The caller's deadline is not.
settle({ kind: "retry" });
};
const timer = setTimeout(cancel, Math.max(0, timeoutMs));
void (async () => {
try {
const response = await fetch(
`${apiBase}/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(flowId)}`,
{ signal: controller.signal },
);
if (settled) { cancelBody(response); return; }
if (response.status === 404) { cancelBody(response); settle({ kind: "missing" }); return; }
if (!response.ok || !response.body) { cancelBody(response); settle({ kind: "retry" }); return; }
reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (settled) return;
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_STATUS_BODY_BYTES) { cancel(); return; }
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
let decoded: unknown;
try { decoded = JSON.parse(text); } catch { settle({ kind: "retry" }); return; }
const view = parseKiroDeviceView(decoded);
settle(view ? { kind: "view", view } : { kind: "retry" });
} catch { settle({ kind: "retry" }); }
})();
return { result, cancel };
}

async function awaitStatusRead(read: KiroStatusRead, ms: number): Promise<KiroStatusResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([read, new Promise<null>(resolve => { timer = setTimeout(() => resolve(null), ms); })]);
return await Promise.race([
read.result,
new Promise<KiroStatusResult>(resolve => {
timer = setTimeout(() => { read.cancel(); resolve({ kind: "retry" }); }, Math.max(0, ms));
}),
]);
} finally { if (timer) clearTimeout(timer); }
}

Expand Down Expand Up @@ -45,7 +113,7 @@ export function observeKiroDeviceFinal(apiBase: string, flowId: string, view: Ki

/** Detached reconciliation survives dialog and page unmount. One loop per flowId. */
export function finalizeKiroDeviceFlow(apiBase: string, flowId: string, expiresAt?: number,
inFlight?: Promise<Response | null>): Promise<KiroFinalOutcome> {
inFlight?: KiroStatusRead): Promise<KiroFinalOutcome> {
const key = keyFor(apiBase, flowId);
const prior = active.get(key);
if (prior) return prior;
Expand All @@ -55,27 +123,23 @@ export function finalizeKiroDeviceFlow(apiBase: string, flowId: string, expiresA
const run = (async () => {
let pending = inFlight;
while (Date.now() < deadline) {
let response: Response | null;
try {
const remaining = deadline - Date.now();
const timeout = Math.min(45_000, remaining);
response = await boundedRead(pending ?? fetch(
`${apiBase}/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(flowId)}`,
{ signal: AbortSignal.timeout(timeout) },
), timeout);
} catch { response = null; }
const remaining = deadline - Date.now();
const read = pending ?? readKiroDeviceStatus(apiBase, flowId, Math.min(45_000, remaining));
pending = undefined;
const status = await awaitStatusRead(read, remaining);
if (terminal.has(key)) return terminal.get(key)!;
if (response?.status === 404) return finish(apiBase, flowId, "ended");
if (response?.ok) {
const view = parseKiroDeviceView(await response.json().catch(() => null));
if (view) {
const result = observeKiroDeviceFinal(apiBase, flowId, view);
if (result) return result;
}
if (status.kind === "missing") return finish(apiBase, flowId, "ended");
if (status.kind === "view") {
const result = observeKiroDeviceFinal(apiBase, flowId, status.view);
if (result) return result;
}
await delay(2_000);
const retryRemaining = deadline - Date.now();
if (retryRemaining <= 0) break;
await delay(Math.min(2_000, retryRemaining));
}
// A close can hand off after expiry. Do not leave that inherited transport alive merely
// because there was no remaining loop iteration in which the deadline wrapper could cancel it.
pending?.cancel();
return finish(apiBase, flowId, "ended");
})().finally(() => { active.delete(key); });
active.set(key, run);
Expand Down
90 changes: 81 additions & 9 deletions gui/tests/kiro-device-login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import type { ProviderAuthHandlers } from "../src/components/provider-workspace/
import { en } from "../src/i18n/en";
import { interpolate, type TFn } from "../src/i18n/shared";
import { useProvidersOAuth } from "../src/pages/use-providers-oauth";
import { finalizeKiroDeviceFlow } from "../src/kiro-device-login-finalizer";
import { subscribeKiroDeviceFinal } from "../src/kiro-device-login-finalizer";
import {
finalizeKiroDeviceFlow, readKiroDeviceStatus, subscribeKiroDeviceFinal,
} from "../src/kiro-device-login-finalizer";

const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
let previous: Record<(typeof globals)[number], unknown>;
Expand Down Expand Up @@ -334,14 +335,19 @@ test("close during start dispatches cancel before detached status", async () =>
unsubscribe();
});

test("close during status body parsing leaves finalizer as sole outcome owner", async () => {
const body = deferred<unknown>();
test("close during a delayed status body transfers its sole reader to the finalizer", async () => {
let bodyController!: ReadableStreamDefaultController<Uint8Array>;
let readingBody = false;
responder = async url => {
if (url.includes("/api/oauth/status?")) {
const response = json(view("done"));
Object.defineProperty(response, "json", { value: () => { readingBody = true; return body.promise; } });
return response;
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
bodyController = controller;
readingBody = true;
controller.enqueue(new TextEncoder().encode(JSON.stringify(view("done"))));
// The terminal JSON is not complete until EOF; close it only after the component unmounts.
},
}), { headers: { "Content-Type": "application/json" } });
}
return url.endsWith("/api/oauth/login/cancel") ? json(view("cancelled")) : json(view("pending"));
};
Expand All @@ -352,13 +358,79 @@ test("close during status body parsing leaves finalizer as sole outcome owner",
expect(readingBody).toBe(true);
await act(async () => { root!.unmount(); root = null; });
await flush();
expect(received).toEqual(["added"]);
await act(async () => { body.resolve(view("done")); }); await flush();
expect(received).toEqual([]);
await act(async () => { bodyController.close(); }); await flush();
expect(settled).toEqual([]);
expect(received).toEqual(["added"]);
expect(requests.filter(r => r.url.includes("/api/oauth/status?"))).toHaveLength(1);
unsubscribe();
});

test("status read deadline covers a body that never reaches EOF", async () => {
let bodyCancelled = 0;
responder = async url => url.includes("/api/oauth/status?")
? new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(view("done"))));
},
cancel() { bodyCancelled++; },
}), { headers: { "Content-Type": "application/json" } })
: json({});
const read = readKiroDeviceStatus("", flowId, 20);
expect(await read.result).toEqual({ kind: "retry" });
expect(bodyCancelled).toBe(1);
});

test("status read deadline settles when fetch ignores abort and discards its late body", async () => {
const pending = deferred<Response>();
let lateBodyCancelled = 0;
responder = async url => url.includes("/api/oauth/status?") ? pending.promise : json({});
const read = readKiroDeviceStatus("", flowId, 20);
expect(await read.result).toEqual({ kind: "retry" });
pending.resolve(new Response(new ReadableStream<Uint8Array>({
cancel() { lateBodyCancelled++; },
})));
await Promise.resolve();
await Promise.resolve();
await new Promise(resolve => setTimeout(resolve, 0));
expect(lateBodyCancelled).toBe(1);
});

test("the overall finalizer deadline cancels a longer inherited body read", async () => {
let bodyCancelled = 0;
responder = async url => url.includes("/api/oauth/status?")
? new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(view("done"))));
},
cancel() { bodyCancelled++; },
}), { headers: { "Content-Type": "application/json" } })
: json({});
const inherited = readKiroDeviceStatus("", flowId, 45_000);
const first = finalizeKiroDeviceFlow("", flowId, Date.now() - 59_980, inherited);
expect(await first).toBe("ended");
expect(bodyCancelled).toBe(1);
const afterCleanup = finalizeKiroDeviceFlow("", flowId, Date.now() + 60_000);
expect(afterCleanup).not.toBe(first);
expect(await afterCleanup).toBe("ended");
});

test("an already-expired finalizer cancels its inherited read without polling", async () => {
let bodyCancelled = 0;
responder = async url => url.includes("/api/oauth/status?")
? new Response(new ReadableStream<Uint8Array>({
cancel() { bodyCancelled++; },
}))
: json({});
const inherited = readKiroDeviceStatus("", flowId, 45_000);
expect(await finalizeKiroDeviceFlow("", flowId, Date.now() - 60_001, inherited)).toBe("ended");
await Promise.resolve();
await Promise.resolve();
await new Promise(resolve => setTimeout(resolve, 0));
expect(bodyCancelled).toBe(1);
expect(requests.filter(r => r.url.includes("/api/oauth/status?"))).toHaveLength(1);
});

test("closing aborts the pending timer and never dispatches a hook status fetch", async () => {
let waitSignal: AbortSignal | undefined;
const cancellableDelay = (_ms: number, signal: AbortSignal) => new Promise<void>(resolve => {
Expand Down
Loading
Loading