Skip to content
Merged
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
5 changes: 4 additions & 1 deletion history.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- fix(ccswitch-import): **running cc-switch → `database is locked`** — importing from cc-switch *while it is open* failed with `导入失败(未检测到 cc-switch 或读取出错): query failed: database is locked` because the read-only connection's first query contends with cc-switch's `BEGIN EXCLUSIVE` write lock (its real contention mode: a valid hot journal under an EXCLUSIVE transaction blocks even read-only readers, unlike `BEGIN IMMEDIATE`). The prior malformed-journal fix only caught `SQLITE_BUSY` on its own escalation path; on the main read path `BUSY` escaped to the catch-all and surfaced as the opaque `query failed: database is locked`. Fix in `server/lib/ccswitch-import.js`: detect `SQLITE_BUSY` on **every** path (the read-only open and the providers query), retry once after a 200ms backoff (cc-switch's write transactions are short — transient locks usually clear), and if still held surface the friendly `cc-switch db is locked (cc-switch may be running); retry shortly` instead of the raw wrapper. New `test/ccswitch-import.test.js` cases use a child process holding `BEGIN EXCLUSIVE` to deterministically reproduce both the held-lock (→ friendly message) and transient-lock (→ retry recovers providers) paths.

- fix(ccswitch-import): **malformed leftover journal → `attempt to write a readonly database`** — importing from cc-switch failed with `导入失败(未检测到 cc-switch 或读取出错): query failed: attempt to write a readonly database` when cc-switch had been killed mid-write and left a **malformed** `cc-switch.db-journal` (truncated/corrupt rollback journal) behind. SQLite must discard a malformed journal to open the DB, which is a write; the read-only connection (`readOnly:true`) refused it → `SQLITE_READONLY`. Root cause locked deterministically: a malformed journal trips recovery; the trigger is a torn/partial journal from an unclean crash. Fix in `server/lib/ccswitch-import.js`: keep the default read-only open, but on `SQLITE_READONLY` escalate **once** to a read-write open guarded by `PRAGMA query_only = ON` (lets SQLite recover/discard the corrupt journal, blocks our own writes), then retry — mirroring what cc-switch itself does on its next normal launch. `SQLITE_BUSY` on the escalation path surfaces a clear "db is locked; retry shortly" message; all other errors still surface their real cause (`query failed: …`, e.g. "file is not a database"). New `test/ccswitch-import.test.js` cases plant a garbage `-journal` next to a valid DB and assert recovery reads providers + cleans the journal, with a follow-up plain read-only read proving the DB is left clean.

- fix(test): `windows-npm-root-regression` 的「cc-viewer 自身目录是最后兜底候选」断言改为与 `resolve(repoRoot, '..')` 比对,不再要求路径以 `node_modules` 结尾——该后缀只在 `npm i -g` 布局下成立,git clone 检出(CI)下父目录是 workspace 目录,导致 CI 失败。

## 1.7.10 (2026-07-26)
Expand Down Expand Up @@ -593,4 +597,3 @@
### 0.0.1 (2026-02-17) — 初始版本

- 拦截并记录 Claude API 请求/响应

91 changes: 76 additions & 15 deletions server/lib/ccswitch-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
// is delegated to the caller (preferences.js POST /api/ccswitch-import).
//
// Design notes:
// - Open SQLite read-only (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running
// - Open SQLite read-only by default (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running;
// escalate once to a read-write + query_only connection only when a malformed leftover journal forces
// SQLITE_READONLY recovery (see readCcSwitchProviders)
// - Multi-path probing: each platform tries the standard Tauri dir first, then falls back to ~/.cc-switch/
// - settings_config parsing is fully fault-tolerant: null / non-JSON / missing env are all skipped, never throws
// - Profile ids get a ccs_ prefix to distinguish from user-created proxy_ prefixed ones; updates are idempotent (re-imports update, never duplicate)
Expand Down Expand Up @@ -155,18 +157,31 @@ export async function readCcSwitchProviders(dbPath) {
// localized message off it (ui.proxy.ccswitchNodeUnsupported).
return { profiles: [], error: 'node:sqlite unavailable on this runtime (requires Node >= 22.5 with --experimental-sqlite, or >= 23.4)' };
}
let db = null;
try {
// Read-only: cc-switch stays unaffected by our reads (no SQLITE_BUSY)
db = new DatabaseSync(dbPath, { readOnly: true });
} catch (err) {
return { profiles: [], error: `cannot open db: ${err && err.message}` };
}
try {

// Open the db. Two contention modes from cc-switch to recover from:
// (1) A leftover MALFORMED cc-switch.db-journal (cc-switch killed mid-write, leaving a
// truncated/corrupt rollback journal) forces SQLite to discard it on the first page
// access — a write — which a read-only connection refuses with SQLITE_READONLY
// ("attempt to write a readonly database"). Escalate once to a read-write open guarded
// by PRAGMA query_only=ON (lets SQLite recover, blocks our own writes), then retry.
// Mirrors what cc-switch itself does on its next normal launch.
// (2) A running cc-switch holding an EXCLUSIVE write lock (its real contention mode — a
// valid hot journal under BEGIN EXCLUSIVE blocks even read-only readers, unlike
// BEGIN IMMEDIATE) makes the read-only connection's first query throw SQLITE_BUSY
// ("database is locked"). Retry once after a short backoff (cc-switch's writes are
// short — transient locks usually clear), and if still held surface a friendly message
// rather than the opaque `query failed: database is locked`.
const primaryErrCode = (e) => Number.isInteger(e && e.errcode) ? (e.errcode & 0xff) : null;
const isReadonlyErr = (e) => primaryErrCode(e) === 8
|| /readonly/i.test(e && (e.errstr || e.message || ''));
const isBusyErr = (e) => [5, 6].includes(primaryErrCode(e))
|| /locked|busy/i.test(e && (e.errstr || e.message || ''));

// Run the providers read against a given connection; returns the result object or throws.
const readProviders = (db) => {
// providers table existence check. A query that throws here means the file is
// unreadable as a SQLite db (corrupt / non-SQLite / truncated) — the real cause
// must surface, not be masked as "table not found". Let it propagate to the
// outer catch, which formats it as `query failed: <message>`.
// must surface, not be masked as "table not found".
const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='providers'").get();
if (!r) return { profiles: [], error: `providers table not found in ${dbPath}` };

Expand All @@ -184,10 +199,56 @@ export async function readCcSwitchProviders(dbPath) {
}
}
return { profiles, currentId, error: null };
} catch (err) {
return { profiles: [], error: `query failed: ${err && err.message}` };
} finally {
try { db.close(); } catch { /* best effort */ }
};

// Open read-only and attempt the read. Two recovery paths:
// - SQLITE_BUSY ("database is locked"): cc-switch is running and holding an EXCLUSIVE write
// lock (its real contention mode — a valid hot journal under BEGIN EXCLUSIVE blocks even
// read-only readers, unlike BEGIN IMMEDIATE). The lock is transient (cc-switch mid-write),
// so retry once after a short backoff; if still held, surface a friendly, actionable
// message instead of the opaque `query failed: database is locked` wrapper.
// - SQLITE_READONLY ("attempt to write a readonly database"): a malformed leftover journal
// (cc-switch killed mid-write) forces SQLite to discard it — a write the read-only
// connection refuses. Escalate once to a read-write open guarded by PRAGMA query_only=ON
// (lets SQLite recover, blocks our own writes), then retry. Mirrors cc-switch's own
// next-launch recovery.
// Other errors propagate to the outer catch (formatted as `query failed: <message>`),
// preserving real-cause surfacing (e.g. "file is not a database").
const LOCKED_MSG = 'cc-switch db is locked (cc-switch may be running); retry shortly';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Keep the BUSY retry and READONLY escalation as independent one-shot budgets. Every open,
// PRAGMA and query attempt passes through the same classifier, so a real state transition such
// as BUSY (cc-switch was writing) → READONLY (it crashed and left a malformed journal) can use
// both recoveries in sequence instead of escaping through a nested retry catch.
let useRecoveryConnection = false;
let busyRetried = false;
while (true) {
let db = null;
let phase = 'open';
try {
db = useRecoveryConnection
? new DatabaseSync(dbPath)
: new DatabaseSync(dbPath, { readOnly: true });
phase = 'query';
if (useRecoveryConnection) db.exec('PRAGMA query_only = ON');
return readProviders(db);
} catch (err) {
if (isBusyErr(err)) {
if (busyRetried) return { profiles: [], error: LOCKED_MSG };
busyRetried = true;
await sleep(200);
continue;
}
if (isReadonlyErr(err) && !useRecoveryConnection) {
useRecoveryConnection = true;
continue;
}
const prefix = phase === 'open' ? 'cannot open db' : 'query failed';
return { profiles: [], error: `${prefix}: ${err && err.message}` };
} finally {
try { db && db.close(); } catch { /* best effort */ }
}
}
}

Expand Down
Loading
Loading