Skip to content

Commit 6d71f27

Browse files
committed
fix(ccswitch-import): recover from malformed leftover journal
Importing from cc-switch failed with "query failed: attempt to write a readonly database" when cc-switch had been killed mid-write, leaving a malformed cc-switch.db-journal (truncated/corrupt rollback journal). SQLite must discard a malformed journal to open the DB — a write the read-only connection (readOnly:true) refused → SQLITE_READONLY. Root cause locked deterministically: a *valid* hot journal is skipped cleanly under readOnly (node:sqlite handles it); only a malformed one trips recovery. So the trigger is a torn/partial journal from an unclean crash, not a clean one. Fix in server/lib/ccswitch-import.js: keep the default read-only open (no SQLITE_BUSY contention with a running cc-switch), but on SQLITE_READONLY escalate once to a read-write open guarded by PRAGMA query_only = ON (lets SQLite recover and 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; other errors still surface their real cause (query failed: …, e.g. file is not a database). Test: 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. Build OK; ccswitch-import 30/30 + api-ccswitch-import 7/7; no new failures vs clean-main baseline.
1 parent dd2fa26 commit 6d71f27

3 files changed

Lines changed: 102 additions & 11 deletions

File tree

history.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- 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 *valid* hot journal is skipped cleanly under `readOnly` (node:sqlite handles it), only a malformed one trips recovery — so the trigger is a torn/partial journal, not a clean crash. Fix in `server/lib/ccswitch-import.js`: keep the default read-only open (no `SQLITE_BUSY` contention with a running cc-switch), 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. Build OK; ccswitch-import 30/30 + api-ccswitch-import 7/7; no new failures vs the clean-main baseline.
6+
57
## 1.7.5 (2026-07-18)
68

79
- ui(proxy): **fuse retry config and stats into a unified split-page**`RetryConfigForm` extracted from RetryConfigModal as inline component; `UnifiedProxyRetryPage` with left config / right stats panels (independent scroll); recent records table filtered to errors only. Shared `isProxyMode()` utility eliminates duplicated proxy-detection logic. Proxy stats toolbar/sidebar buttons removed; unified page now reachable via hamburger menu. P1–P2 code-review fixes applied.

server/lib/ccswitch-import.js

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
// is delegated to the caller (preferences.js POST /api/ccswitch-import).
1212
//
1313
// Design notes:
14-
// - Open SQLite read-only (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running
14+
// - Open SQLite read-only by default (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running;
15+
// escalate once to a read-write + query_only connection only when a malformed leftover journal forces
16+
// SQLITE_READONLY recovery (see readCcSwitchProviders)
1517
// - Multi-path probing: each platform tries the standard Tauri dir first, then falls back to ~/.cc-switch/
1618
// - settings_config parsing is fully fault-tolerant: null / non-JSON / missing env are all skipped, never throws
1719
// - Profile ids get a ccs_ prefix to distinguish from user-created proxy_ prefixed ones; updates are idempotent (re-imports update, never duplicate)
@@ -155,18 +157,23 @@ export async function readCcSwitchProviders(dbPath) {
155157
// localized message off it (ui.proxy.ccswitchNodeUnsupported).
156158
return { profiles: [], error: 'node:sqlite unavailable on this runtime (requires Node >= 22.5 with --experimental-sqlite, or >= 23.4)' };
157159
}
158-
let db = null;
159-
try {
160-
// Read-only: cc-switch stays unaffected by our reads (no SQLITE_BUSY)
161-
db = new DatabaseSync(dbPath, { readOnly: true });
162-
} catch (err) {
163-
return { profiles: [], error: `cannot open db: ${err && err.message}` };
164-
}
165-
try {
160+
161+
// Open the db. Default: read-only (no SQLITE_BUSY contention with a running cc-switch).
162+
// But a leftover MALFORMED cc-switch.db-journal (cc-switch killed mid-write, leaving a
163+
// truncated/corrupt rollback journal) forces SQLite to discard it on the first page access
164+
// — a write — which a read-only connection refuses with SQLITE_READONLY
165+
// ("attempt to write a readonly database"). A *valid* hot journal is skipped cleanly under
166+
// readOnly; only a malformed one trips this. On that specific error we escalate once to a
167+
// read-write open guarded by PRAGMA query_only=ON (lets SQLite recover, blocks our writes),
168+
// then retry. This mirrors what cc-switch itself does on its next normal launch.
169+
const isReadonlyErr = (e) => /readonly/i.test(e && (e.errstr || e.message || ''));
170+
const isBusyErr = (e) => /locked|busy/i.test(e && (e.errstr || e.message || ''));
171+
172+
// Run the providers read against a given connection; returns the result object or throws.
173+
const readProviders = (db) => {
166174
// providers table existence check. A query that throws here means the file is
167175
// unreadable as a SQLite db (corrupt / non-SQLite / truncated) — the real cause
168-
// must surface, not be masked as "table not found". Let it propagate to the
169-
// outer catch, which formats it as `query failed: <message>`.
176+
// must surface, not be masked as "table not found".
170177
const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='providers'").get();
171178
if (!r) return { profiles: [], error: `providers table not found in ${dbPath}` };
172179

@@ -184,6 +191,41 @@ export async function readCcSwitchProviders(dbPath) {
184191
}
185192
}
186193
return { profiles, currentId, error: null };
194+
};
195+
196+
// Open read-only and attempt the read. On SQLITE_READONLY (malformed journal), escalate
197+
// to a read-write + query_only connection and retry once. Other errors propagate to the
198+
// outer catch (formatted as `query failed: <message>`), preserving real-cause surfacing.
199+
let db = null;
200+
try {
201+
try {
202+
db = new DatabaseSync(dbPath, { readOnly: true });
203+
} catch (err) {
204+
// Open itself can fail SQLITE_READONLY if the malformed journal is detected at open.
205+
if (!isReadonlyErr(err)) return { profiles: [], error: `cannot open db: ${err && err.message}` };
206+
db = new DatabaseSync(dbPath); // read-write to let SQLite discard the corrupt journal
207+
db.exec('PRAGMA query_only = ON');
208+
}
209+
try {
210+
return readProviders(db);
211+
} catch (err) {
212+
if (!isReadonlyErr(err)) throw err; // surface the real cause (e.g. "file is not a database")
213+
// Escalate: reopen read-write, recover, lock to query-only, retry once.
214+
try { db.close(); } catch { /* best effort */ }
215+
try {
216+
db = new DatabaseSync(dbPath);
217+
} catch (openErr) {
218+
if (isBusyErr(openErr)) return { profiles: [], error: 'cc-switch db is locked (cc-switch may be running); retry shortly' };
219+
throw openErr;
220+
}
221+
db.exec('PRAGMA query_only = ON');
222+
try {
223+
return readProviders(db);
224+
} catch (retryErr) {
225+
if (isBusyErr(retryErr)) return { profiles: [], error: 'cc-switch db is locked (cc-switch may be running); retry shortly' };
226+
throw retryErr;
227+
}
228+
}
187229
} catch (err) {
188230
return { profiles: [], error: `query failed: ${err && err.message}` };
189231
} finally {

test/ccswitch-import.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,50 @@ describe('readCcSwitchProviders (error surfacing)', { skip: !hasSqlite }, () =>
428428
assert.ok(error.includes(dbPath), `error should name the resolved db path, got: ${error}`);
429429
});
430430
});
431+
432+
// Regression: a malformed leftover rollback journal (cc-switch crashed mid-write, leaving a
433+
// truncated/corrupt cc-switch.db-journal) makes a read-only SQLite connection fail to open the
434+
// first query with SQLITE_READONLY ("attempt to write a readonly database"). SQLite needs to
435+
// discard the corrupt journal to open the DB, which is a write — refused under readOnly:true.
436+
// The fix escalates to a read-write open (query-only locked) on that specific error, lets SQLite
437+
// recover, then reads. Reproduced deterministically by planting a non-journal garbage file next
438+
// to an otherwise-valid DB. (A *valid* hot journal does NOT trigger this — node:sqlite skips it —
439+
// only a malformed one does, so the test plants garbage bytes.)
440+
describe('readCcSwitchProviders (malformed journal recovery)', { skip: !hasSqlite }, () => {
441+
let tmpDir;
442+
before(() => { tmpDir = mkdtempSync(join(tmpdir(), 'ccv-ccswitch-journal-')); });
443+
after(() => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ } });
444+
445+
it('残留的损坏 journal 不再以 readonly 报错,而是照常读出 providers', async () => {
446+
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
447+
const dbPath = join(tmpDir, 'cc-switch.db');
448+
// Build a valid cc-switch-shaped DB with one claude provider committed.
449+
const db = new DatabaseSync(dbPath);
450+
db.exec('PRAGMA journal_mode=DELETE');
451+
db.exec('CREATE TABLE providers(id INTEGER PRIMARY KEY, app_type TEXT, name TEXT, settings_config TEXT, is_current INTEGER, sort_index INTEGER)');
452+
db.exec(`INSERT INTO providers(app_type,name,settings_config,is_current,sort_index) VALUES('claude','xfyun','{"env":{"ANTHROPIC_BASE_URL":"https://x","ANTHROPIC_AUTH_TOKEN":"tok"}}',1,0)`);
453+
db.close();
454+
// Plant the locked trigger: a stale -journal file whose bytes are NOT a valid rollback
455+
// journal. This is what cc-switch leaves behind when it is killed mid-write (truncated /
456+
// torn page, or a partially flushed journal that fails SQLite's header magic check).
457+
writeFileSync(dbPath + '-journal', 'NOT A VALID ROLLBACK JOURNAL - GARBAGE BYTES');
458+
459+
const { profiles, error } = await readCcSwitchProviders(dbPath);
460+
assert.equal(error, null, `expected recovery, got error: ${error}`);
461+
assert.ok(Array.isArray(profiles) && profiles.length === 1, `expected 1 profile, got: ${JSON.stringify(profiles)}`);
462+
assert.equal(profiles[0].name, 'xfyun');
463+
assert.ok(profiles[0].apiKey, 'credential should map through');
464+
// The malformed journal must be cleaned up as part of recovery (SQLite discards it on the
465+
// read-write open), so a subsequent read-only open works without any escalation.
466+
assert.ok(!existsSync(dbPath + '-journal'), 'malformed journal should be discarded after recovery');
467+
});
468+
469+
it('recovery 之后用纯只读二次读取也能成功(journal 已清)', async () => {
470+
// After the previous test recovered and removed the malformed journal, a plain read-only
471+
// open must succeed on its own — proving the fix left the DB in a clean state.
472+
const dbPath = join(tmpDir, 'cc-switch.db');
473+
const { profiles, error } = await readCcSwitchProviders(dbPath);
474+
assert.equal(error, null);
475+
assert.ok(profiles.length === 1 && profiles[0].name === 'xfyun');
476+
});
477+
});

0 commit comments

Comments
 (0)