| author | |
| committer | |
| log | 9381533771ef50d58304f79fac2b74ebd8932be7 |
| tree | 77b0069272338ce54714d53f2c08c10547fe841a |
| parent | 13c5328dd02ab0f5863f7d46455efc8dc243d4ce |
| signature | Signed by PGP key B5690EEEBB952194 |
Keep up to six Cursor usage pages requested ahead of the one being read, still read in page order, and cancel requests left in flight when the read ends.
Continues #14384.
Co-authored-by: Krishna Vijay <228381532+im-kvijay@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>2 files changed, 92 insertions(+), 2 deletions(-)
apps/server/src/usage/cursorUsageReader.test.ts+71| ... | @@ -19,4 +19,75 @@ describe("readCursorAccountUsage", () => { | ... | @@ -19,4 +19,75 @@ describe("readCursorAccountUsage", () => { |
| 19 | error: "Allow Keychain access on the Mac running T3 Code, then refresh.", | 19 | error: "Allow Keychain access on the Mac running T3 Code, then refresh.", |
| 20 | }); | 20 | }); |
| 21 | }); | 21 | }); |
| 22 | |||
| 23 | it("reads the pages behind the first together and keeps them in page order", async () => { | ||
| 24 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | ||
| 25 | let inFlight = 0; | ||
| 26 | let mostInFlight = 0; | ||
| 27 | const result = await readCursorAccountUsage( | ||
| 28 | { kind: "keychain" }, | ||
| 29 | 0, | ||
| 30 | 1781000000000, | ||
| 31 | async (_url, init) => { | ||
| 32 | const page: number = JSON.parse(String(init.body)).page; | ||
| 33 | inFlight++; | ||
| 34 | mostInFlight = Math.max(mostInFlight, inFlight); | ||
| 35 | // Later pages answer first, so record order cannot come from arrival order. | ||
| 36 | for (let turn = page; turn < 9; turn++) await Promise.resolve(); | ||
| 37 | inFlight--; | ||
| 38 | return Response.json({ | ||
| 39 | totalUsageEventsCount: 8001, | ||
| 40 | usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ | ||
| 41 | timestamp: String(1780000000000 + (page - 1) * 1000 + index), | ||
| 42 | model: "gpt-5", | ||
| 43 | tokenUsage: { inputTokens: 1 }, | ||
| 44 | })), | ||
| 45 | }); | ||
| 46 | }, | ||
| 47 | async () => accessToken, | ||
| 48 | ); | ||
| 49 | assert.isNull(result.error); | ||
| 50 | assert.strictEqual(mostInFlight, 6); | ||
| 51 | assert.deepStrictEqual( | ||
| 52 | result.records.map((record) => record.timestampMs), | ||
| 53 | Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), | ||
| 54 | ); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("ends like a page-by-page read when a page fails, leaving no request open", async () => { | ||
| 58 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | ||
| 59 | const fullPage = { | ||
| 60 | totalUsageEventsCount: 8001, | ||
| 61 | usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), | ||
| 62 | }; | ||
| 63 | // Page 3 fails while the pages after it are still waiting on Cursor. | ||
| 64 | for (const [secondPage, error] of [ | ||
| 65 | [() => Response.json(fullPage), "Cursor account usage could not be read."], | ||
| 66 | [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."], | ||
| 67 | ] as const) { | ||
| 68 | let open = 0; | ||
| 69 | const result = await readCursorAccountUsage( | ||
| 70 | { kind: "keychain" }, | ||
| 71 | 0, | ||
| 72 | 1781000000000, | ||
| 73 | (_url, init) => { | ||
| 74 | const page: number = JSON.parse(String(init.body)).page; | ||
| 75 | if (page === 1) return Promise.resolve(Response.json(fullPage)); | ||
| 76 | if (page === 2) return Promise.resolve(secondPage()); | ||
| 77 | if (page === 3) return Promise.reject(new Error("connection reset")); | ||
| 78 | open++; | ||
| 79 | return new Promise((_resolve, reject) => { | ||
| 80 | init.signal?.addEventListener("abort", () => { | ||
| 81 | open--; | ||
| 82 | reject(init.signal?.reason); | ||
| 83 | }); | ||
| 84 | }); | ||
| 85 | }, | ||
| 86 | async () => accessToken, | ||
| 87 | ); | ||
| 88 | assert.strictEqual(result.error, error); | ||
| 89 | assert.deepStrictEqual(result.records, []); | ||
| 90 | assert.strictEqual(open, 0); | ||
| 91 | } | ||
| 92 | }); | ||
| 22 | }); | 93 | }); |
apps/server/src/usage/cursorUsageReader.ts+21-2| ... | @@ -106,6 +106,7 @@ export async function readCursorAccountUsage( | ... | @@ -106,6 +106,7 @@ export async function readCursorAccountUsage( |
| 106 | }; | 106 | }; |
| 107 | } | 107 | } |
| 108 | let accountKey: string | null = null; | 108 | let accountKey: string | null = null; |
| 109 | const cancel = new AbortController(); | ||
| 109 | try { | 110 | try { |
| 110 | const payload = accessToken.split(".")[1]; | 111 | const payload = accessToken.split(".")[1]; |
| 111 | const subject = object( | 112 | const subject = object( |
| ... | @@ -117,14 +118,14 @@ export async function readCursorAccountUsage( | ... | @@ -117,14 +118,14 @@ export async function readCursorAccountUsage( |
| 117 | accountKey = accountHash(subject); | 118 | accountKey = accountHash(subject); |
| 118 | if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) | 119 | if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) |
| 119 | throw new Error("Invalid date window"); | 120 | throw new Error("Invalid date window"); |
| 120 | const deadline = AbortSignal.timeout(60_000); | 121 | const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]); |
| 121 | const records: UsageRecord[] = []; | 122 | const records: UsageRecord[] = []; |
| 122 | const occurrences = new Map<string, number>(); | 123 | const occurrences = new Map<string, number>(); |
| 123 | const pages: unknown[][] = []; | 124 | const pages: unknown[][] = []; |
| 124 | let completed = false; | 125 | let completed = false; |
| 125 | const pageSize = 1000; | 126 | const pageSize = 1000; |
| 126 | let total: number | undefined; | 127 | let total: number | undefined; |
| 127 | for (let page = 1; ; page++) { | 128 | const readPage = async (page: number) => { |
| 128 | // A count can include overlapping page boundaries. Allow room to | 129 | // A count can include overlapping page boundaries. Allow room to |
| 129 | // reconcile them without imposing a fixed account-size limit. | 130 | // reconcile them without imposing a fixed account-size limit. |
| 130 | if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { | 131 | if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { |
| ... | @@ -181,6 +182,21 @@ export async function readCursorAccountUsage( | ... | @@ -181,6 +182,21 @@ export async function readCursorAccountUsage( |
| 181 | throw new Error("Inconsistent account usage page"); | 182 | throw new Error("Inconsistent account usage page"); |
| 182 | } | 183 | } |
| 183 | if (typeof count === "number") total = count; | 184 | if (typeof count === "number") total = count; |
| 185 | return events; | ||
| 186 | }; | ||
| 187 | // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages | ||
| 188 | // the first one's count implies are requested up to six ahead and consumed in page order. | ||
| 189 | const ahead: ReturnType<typeof readPage>[] = []; | ||
| 190 | for (let page = 1; ; page++) { | ||
| 191 | while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { | ||
| 192 | const pending = readPage(page + ahead.length); | ||
| 193 | // Handled when its turn comes, or cancelled if the read ends first. | ||
| 194 | pending.catch(() => undefined); | ||
| 195 | ahead.push(pending); | ||
| 196 | } | ||
| 197 | const events = await (ahead.shift() ?? readPage(page)); | ||
| 198 | // A rejected login comes back as the finished result instead of a page. | ||
| 199 | if (!Array.isArray(events)) return events; | ||
| 184 | pages.push(events); | 200 | pages.push(events); |
| 185 | if (events.length < pageSize) { | 201 | if (events.length < pageSize) { |
| 186 | completed = true; | 202 | completed = true; |
| ... | @@ -268,5 +284,8 @@ export async function readCursorAccountUsage( | ... | @@ -268,5 +284,8 @@ export async function readCursorAccountUsage( |
| 268 | missing: false, | 284 | missing: false, |
| 269 | error: "Cursor account usage could not be read.", | 285 | error: "Cursor account usage could not be read.", |
| 270 | }; | 286 | }; |
| 287 | } finally { | ||
| 288 | // An early exit leaves the pages requested ahead of it in flight. | ||
| 289 | cancel.abort(); | ||
| 271 | } | 290 | } |
| 272 | } | 291 | } |