From e89c5d4df01013985735da525b9de2785bc22024 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 20:23:54 +0900 Subject: [PATCH 1/3] fix(rate-limits): skip a huge Cursor desktop WAL on the main thread The sqlite timeout only bounds a lock wait. Replaying a multi-GB WAL blocked the UI on focus. A normal database still supplies the login. Fixes #24360 --- .../cursor-desktop-state-db.test.ts | 66 +++++++++++++++++++ .../rate-limits/cursor-desktop-state-db.ts | 18 ++++- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/main/rate-limits/cursor-desktop-state-db.test.ts diff --git a/src/main/rate-limits/cursor-desktop-state-db.test.ts b/src/main/rate-limits/cursor-desktop-state-db.test.ts new file mode 100644 index 000000000000..5738b02c2bce --- /dev/null +++ b/src/main/rate-limits/cursor-desktop-state-db.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import { CURSOR_DESKTOP_WAL_SKIP_BYTES, readCursorDesktopProfile } from './cursor-desktop-state-db' + +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'cursor-desktop-')) + dirs.push(dir) + return dir +} + +function seedDatabase(dir: string): string { + const dbPath = join(dir, 'state.vscdb') + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE ItemTable (key TEXT, value TEXT)') + db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run( + 'cursorAuth/accessToken', + 'token-1' + ) + db.close() + return dbPath +} + +describe('readCursorDesktopProfile', () => { + it('reads a desktop login from a normal database', () => { + const read = readCursorDesktopProfile(seedDatabase(tempDir())) + expect(read).toEqual({ + status: 'ok', + profile: { + accessToken: 'token-1', + email: null, + membershipType: null, + subscriptionStatus: null + } + }) + }) + + it('reports a missing database without opening one', () => { + expect(readCursorDesktopProfile(join(tempDir(), 'missing.vscdb'))).toEqual({ + status: 'missing' + }) + }) + + it('does not open a database whose WAL is large enough to stall the main thread', () => { + const dbPath = join(tempDir(), 'state.vscdb') + writeFileSync(dbPath, 'not-a-database') + const walPath = `${dbPath}-wal` + writeFileSync(walPath, '') + truncateSync(walPath, CURSOR_DESKTOP_WAL_SKIP_BYTES + 1) + + expect(readCursorDesktopProfile(dbPath)).toEqual({ + status: 'error', + error: 'Cursor desktop login database is too large to open here' + }) + }) +}) diff --git a/src/main/rate-limits/cursor-desktop-state-db.ts b/src/main/rate-limits/cursor-desktop-state-db.ts index bdc50c7390d6..9851ee78f2d6 100644 --- a/src/main/rate-limits/cursor-desktop-state-db.ts +++ b/src/main/rate-limits/cursor-desktop-state-db.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { z } from 'zod' import SyncDatabase from '../sqlite/sync-database' @@ -7,6 +7,10 @@ const EMAIL_KEY = 'cursorAuth/cachedEmail' const MEMBERSHIP_KEY = 'cursorAuth/stripeMembershipType' const SUBSCRIPTION_KEY = 'cursorAuth/stripeSubscriptionStatus' const OPEN_TIMEOUT_MS = 250 +// Why: node:sqlite's timeout only bounds a lock wait. Opening a multi-GB WAL +// replays it on the main thread before the first prepare returns (#24360). +export const CURSOR_DESKTOP_WAL_SKIP_BYTES = 32 * 1024 * 1024 +const WAL_TOO_LARGE_ERROR = 'Cursor desktop login database is too large to open here' export type CursorDesktopProfile = { accessToken: string | null @@ -33,10 +37,22 @@ function valueAsString(value: unknown): string | null { } /** Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB. */ +function cursorDesktopWalBytes(dbPath: string): number { + try { + const stat = statSync(`${dbPath}-wal`) + return stat.isFile() ? stat.size : 0 + } catch { + return 0 + } +} + export function readCursorDesktopProfile(dbPath: string): CursorDesktopProfileReadResult { if (!existsSync(dbPath)) { return { status: 'missing' } } + if (cursorDesktopWalBytes(dbPath) > CURSOR_DESKTOP_WAL_SKIP_BYTES) { + return { status: 'error', error: WAL_TOO_LARGE_ERROR } + } let db: SyncDatabase | null = null try { db = new SyncDatabase(dbPath, { From 9638785a87b5ca398c9bb83863e2c8ccd2a4b59c Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 20:51:14 +0900 Subject: [PATCH 2/3] fix(rate-limits): skip a Cursor WAL that cannot be measured A stat failure other than a missing file used to look like no WAL, so the main thread still opened the database. An oversized or unreadable WAL is now a skip, not a login error, and a small WAL still returns its token. --- .../cursor-desktop-state-db.test.ts | 67 +++++++++++++++++-- .../rate-limits/cursor-desktop-state-db.ts | 23 +++++-- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/main/rate-limits/cursor-desktop-state-db.test.ts b/src/main/rate-limits/cursor-desktop-state-db.test.ts index 5738b02c2bce..2f837e43f133 100644 --- a/src/main/rate-limits/cursor-desktop-state-db.test.ts +++ b/src/main/rate-limits/cursor-desktop-state-db.test.ts @@ -1,10 +1,24 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CURSOR_DESKTOP_WAL_SKIP_BYTES, readCursorDesktopProfile } from './cursor-desktop-state-db' +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + statSync: (...args: Parameters) => { + if (String(args[0]).endsWith('stat-denied.vscdb-wal')) { + throw Object.assign(new Error('denied'), { code: 'EACCES' }) + } + return actual.statSync(...args) + } + } +}) + const dirs: string[] = [] afterEach(() => { @@ -19,8 +33,8 @@ function tempDir(): string { return dir } -function seedDatabase(dir: string): string { - const dbPath = join(dir, 'state.vscdb') +function seedDatabase(dir: string, name = 'state.vscdb'): string { + const dbPath = join(dir, name) const db = new DatabaseSync(dbPath) db.exec('CREATE TABLE ItemTable (key TEXT, value TEXT)') db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run( @@ -31,6 +45,18 @@ function seedDatabase(dir: string): string { return dbPath } +function seedWalDatabase(dir: string): { dbPath: string; close: () => void } { + const dbPath = join(dir, 'state.vscdb') + const db = new DatabaseSync(dbPath) + db.exec('PRAGMA journal_mode = WAL') + db.exec('CREATE TABLE ItemTable (key TEXT, value TEXT)') + db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run( + 'cursorAuth/accessToken', + 'wal-token' + ) + return { dbPath, close: () => db.close() } +} + describe('readCursorDesktopProfile', () => { it('reads a desktop login from a normal database', () => { const read = readCursorDesktopProfile(seedDatabase(tempDir())) @@ -51,6 +77,25 @@ describe('readCursorDesktopProfile', () => { }) }) + it('reads a login from a WAL database that is below the skip limit', () => { + const seeded = seedWalDatabase(tempDir()) + try { + expect(statSync(`${seeded.dbPath}-wal`).isFile()).toBe(true) + expect(statSync(`${seeded.dbPath}-wal`).size).toBeLessThanOrEqual(CURSOR_DESKTOP_WAL_SKIP_BYTES) + expect(readCursorDesktopProfile(seeded.dbPath)).toEqual({ + status: 'ok', + profile: { + accessToken: 'wal-token', + email: null, + membershipType: null, + subscriptionStatus: null + } + }) + } finally { + seeded.close() + } + }) + it('does not open a database whose WAL is large enough to stall the main thread', () => { const dbPath = join(tempDir(), 'state.vscdb') writeFileSync(dbPath, 'not-a-database') @@ -59,8 +104,18 @@ describe('readCursorDesktopProfile', () => { truncateSync(walPath, CURSOR_DESKTOP_WAL_SKIP_BYTES + 1) expect(readCursorDesktopProfile(dbPath)).toEqual({ - status: 'error', - error: 'Cursor desktop login database is too large to open here' + status: 'skipped', + reason: 'wal-too-large' + }) + }) + + it('does not open a database when the WAL size cannot be read', () => { + const dbPath = join(tempDir(), 'stat-denied.vscdb') + writeFileSync(dbPath, 'not-a-database') + + expect(readCursorDesktopProfile(dbPath)).toEqual({ + status: 'skipped', + reason: 'wal-unreadable' }) }) }) diff --git a/src/main/rate-limits/cursor-desktop-state-db.ts b/src/main/rate-limits/cursor-desktop-state-db.ts index 9851ee78f2d6..587c6d727c67 100644 --- a/src/main/rate-limits/cursor-desktop-state-db.ts +++ b/src/main/rate-limits/cursor-desktop-state-db.ts @@ -10,7 +10,6 @@ const OPEN_TIMEOUT_MS = 250 // Why: node:sqlite's timeout only bounds a lock wait. Opening a multi-GB WAL // replays it on the main thread before the first prepare returns (#24360). export const CURSOR_DESKTOP_WAL_SKIP_BYTES = 32 * 1024 * 1024 -const WAL_TOO_LARGE_ERROR = 'Cursor desktop login database is too large to open here' export type CursorDesktopProfile = { accessToken: string | null @@ -22,6 +21,7 @@ export type CursorDesktopProfile = { export type CursorDesktopProfileReadResult = | { status: 'missing' } | { status: 'error'; error: string } + | { status: 'skipped'; reason: 'wal-too-large' | 'wal-unreadable' } | { status: 'ok'; profile: CursorDesktopProfile } const rowsSchema = z.array(z.object({ key: z.unknown(), value: z.unknown() }).partial()) @@ -36,22 +36,31 @@ function valueAsString(value: unknown): string | null { return null } -/** Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB. */ -function cursorDesktopWalBytes(dbPath: string): number { +function isMissingPath(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +/** Size of the sibling `-wal` file. A missing file is 0. Any other stat failure is unreadable. */ +function cursorDesktopWalBytes(dbPath: string): number | null { try { const stat = statSync(`${dbPath}-wal`) return stat.isFile() ? stat.size : 0 - } catch { - return 0 + } catch (error) { + return isMissingPath(error) ? 0 : null } } +/** Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB. */ export function readCursorDesktopProfile(dbPath: string): CursorDesktopProfileReadResult { if (!existsSync(dbPath)) { return { status: 'missing' } } - if (cursorDesktopWalBytes(dbPath) > CURSOR_DESKTOP_WAL_SKIP_BYTES) { - return { status: 'error', error: WAL_TOO_LARGE_ERROR } + const walBytes = cursorDesktopWalBytes(dbPath) + if (walBytes === null) { + return { status: 'skipped', reason: 'wal-unreadable' } + } + if (walBytes > CURSOR_DESKTOP_WAL_SKIP_BYTES) { + return { status: 'skipped', reason: 'wal-too-large' } } let db: SyncDatabase | null = null try { From 72eb00055bbb4d68eac47293d69e6a7d4e22edfd Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 21:05:37 +0900 Subject: [PATCH 3/3] test(rate-limits): drop the unused database name argument seedDatabase always writes state.vscdb. The extra parameter had no caller. --- src/main/rate-limits/cursor-desktop-state-db.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/rate-limits/cursor-desktop-state-db.test.ts b/src/main/rate-limits/cursor-desktop-state-db.test.ts index 2f837e43f133..f04705cff160 100644 --- a/src/main/rate-limits/cursor-desktop-state-db.test.ts +++ b/src/main/rate-limits/cursor-desktop-state-db.test.ts @@ -33,8 +33,8 @@ function tempDir(): string { return dir } -function seedDatabase(dir: string, name = 'state.vscdb'): string { - const dbPath = join(dir, name) +function seedDatabase(dir: string): string { + const dbPath = join(dir, 'state.vscdb') const db = new DatabaseSync(dbPath) db.exec('CREATE TABLE ItemTable (key TEXT, value TEXT)') db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(