TL;DR (English): Every normal WCDB handle opens with the private flag SQLITE_OPEN_MAINDB_READONLY. VACUUM INTO attaches its destination with the connection's db->openFlags, so the flag propagates to vacuum_db; unixOpen then creates the destination with an O_RDONLY|O_CREAT fd (the flag clears isReadWrite but keeps isCreate), and the first write lock on it fails deterministically with EBADF → SQLITE_IOERR_LOCK "disk I/O error". Net effect: VACUUM INTO fails on every database that is already in WAL mode when the connection opens — i.e. every real-world database from its second session onward. Fix PR: Tencent/sqlcipher#8.
环境
- WCDB 2.1.15(iOS 侧从
v2.1.15 tag 源码构建复现;master 的 AbstractHandle.cpp 与 sqlcipher submodule pin(f049bed)与 tag 相同,机制仍在)
- iOS 真机 + 模拟器均 100% 复现
根因链
- 常规 handle 主库只读:
AbstractHandle::open() 以 READWRITE|CREATE|MAINDB_READONLY(0x100006)打开连接(src/common/core/sqlite/AbstractHandle.cpp:113)。设计意图可以理解:WAL 下写都进 -wal,只有 checkpoint 等专用 slot(InnerDatabase::setupHandle 里的 AutoTask/Assemble/Vacuum)才需要可写主库 fd。
- flag 存活到 ATTACH:
openDatabase 的 strip 清单不含该位 → db->openFlags 保留它 → attachFunc 的 flags = db->openFlags(attach.c:147)→ VACUUM INTO 内部的 ATTACH %Q AS vacuum_db(vacuum.c:214)把 flag 传给了目标文件。
- O_RDONLY|O_CREAT:
unixOpen(os_unix.c)对该 flag 清 isReadWrite 但保留 isCreate → 目标文件以 O_RDONLY|O_CREAT 创建成功(文件建出来了,fd 却只读;上游 stock SQLite 的 assert(isCreate==0 || isReadWrite) 在 fork 里被注释掉了)。
- EBADF:ATTACH 读空 schema 成功(
F_RDLCK 在只读 fd 上合法);VACUUM 拷贝开写事务拿 RESERVED 锁 → fcntl(F_WRLCK) 打在只读 fd 上,Linux/Darwin 内核都强制返回 EBADF → sqliteErrorFromPosixError(EBADF, SQLITE_IOERR_LOCK):
Code:IOError ExtCode:3850 (SQLITE_IOERR_LOCK) Message:"disk I/O error" SystemErrno:9 (EBADF)
- 为什么常规测试测不到(伪阴性):全新库首个连接的 BasicConfig 要执行
PRAGMA journal_mode=WAL(写主库)→ 在只读主库 fd 上失败 → 触发 InnerHandle::open 的回退(enableWriteMainDB(true) + 重开)→ 该连接全程可写,VACUUM INTO 成功。所以"新建库 → 立即 VACUUM INTO"的测试全部通过;而已是 WAL 的库(真实用户库的第二个 session 起)100% 失败。
复现步骤
- 用 WCDB 打开一个库,写入数据(库转为 WAL),关闭;
- 重新打开同一个库(此时已是 WAL,可写重开回退不再触发);
- 任意 handle 执行
VACUUM INTO '/some/new/path.db'(如 Android handle.preparedWithMainStatement(...));
- → 恒定
SQLITE_IOERR_LOCK(3850)+ errno 9(EBADF)。
影响面
VACUUM INTO:如上,已 WAL 库上恒失败;
- 普通 SQL
VACUUM:其临时库的 ATTACH '' 同样继承 flag,同样中招(Database::vacuum() 的 Factory 实现不走 SQL VACUUM,故未暴露);
- 广义:任何通过常规 handle 对 ATTACH 副库的写入都会命中同一失败(副库 fd 只读,pager 却认为可写)。
修复建议
已提最小修复 PR(vacuum 路径,ATTACH 前后 save/clear/restore 该 flag,恢复 stock SQLite 行为):Tencent/sqlcipher#8。
如果想根治广义问题,可以考虑在 attach.c 层面对非主库的 ATTACH 清掉该 flag——是否合适请维护者定夺(比如 checkpoint 语义是否依赖 ATTACH 库的只读主 fd)。
附带观察(供排查发布构建,非本 issue 主体)
Android Maven AAR(com.tencent.wcdb:main:2.1.15)实测不复现此问题:运行中进程 /proc/<pid>/fdinfo 显示主库 fd 为 O_RDWR,同库 VACUUM INTO 成功。但对该 AAR 的 libWCDB.so 反汇编显示 AbstractHandle::open 的 0x100006 调用与 unixOpen 的 bit-20 掩码逻辑都在,且无任何清位指令——即发布的 Android 二进制运行时行为与 v2.1.15 tag 源码语义不一致(iOS 从同一 tag 源码构建则忠实复现 bug)。供团队排查 Android 发布管线时参考。
TL;DR (English): Every normal WCDB handle opens with the private flag
SQLITE_OPEN_MAINDB_READONLY.VACUUM INTOattaches its destination with the connection'sdb->openFlags, so the flag propagates tovacuum_db;unixOpenthen creates the destination with anO_RDONLY|O_CREATfd (the flag clearsisReadWritebut keepsisCreate), and the first write lock on it fails deterministically withEBADF→SQLITE_IOERR_LOCK"disk I/O error". Net effect:VACUUM INTOfails on every database that is already in WAL mode when the connection opens — i.e. every real-world database from its second session onward. Fix PR: Tencent/sqlcipher#8.环境
v2.1.15tag 源码构建复现;master的AbstractHandle.cpp与sqlciphersubmodule pin(f049bed)与 tag 相同,机制仍在)根因链
AbstractHandle::open()以READWRITE|CREATE|MAINDB_READONLY(0x100006)打开连接(src/common/core/sqlite/AbstractHandle.cpp:113)。设计意图可以理解:WAL 下写都进-wal,只有 checkpoint 等专用 slot(InnerDatabase::setupHandle里的AutoTask/Assemble/Vacuum)才需要可写主库 fd。openDatabase的 strip 清单不含该位 →db->openFlags保留它 →attachFunc的flags = db->openFlags(attach.c:147)→VACUUM INTO内部的ATTACH %Q AS vacuum_db(vacuum.c:214)把 flag 传给了目标文件。unixOpen(os_unix.c)对该 flag 清isReadWrite但保留isCreate→ 目标文件以O_RDONLY|O_CREAT创建成功(文件建出来了,fd 却只读;上游 stock SQLite 的assert(isCreate==0 || isReadWrite)在 fork 里被注释掉了)。F_RDLCK在只读 fd 上合法);VACUUM 拷贝开写事务拿 RESERVED 锁 →fcntl(F_WRLCK)打在只读 fd 上,Linux/Darwin 内核都强制返回EBADF→sqliteErrorFromPosixError(EBADF, SQLITE_IOERR_LOCK):PRAGMA journal_mode=WAL(写主库)→ 在只读主库 fd 上失败 → 触发InnerHandle::open的回退(enableWriteMainDB(true)+ 重开)→ 该连接全程可写,VACUUM INTO 成功。所以"新建库 → 立即 VACUUM INTO"的测试全部通过;而已是 WAL 的库(真实用户库的第二个 session 起)100% 失败。复现步骤
VACUUM INTO '/some/new/path.db'(如 Androidhandle.preparedWithMainStatement(...));SQLITE_IOERR_LOCK(3850)+ errno 9(EBADF)。影响面
VACUUM INTO:如上,已 WAL 库上恒失败;VACUUM:其临时库的ATTACH ''同样继承 flag,同样中招(Database::vacuum()的 Factory 实现不走 SQL VACUUM,故未暴露);修复建议
已提最小修复 PR(vacuum 路径,ATTACH 前后 save/clear/restore 该 flag,恢复 stock SQLite 行为):Tencent/sqlcipher#8。
如果想根治广义问题,可以考虑在
attach.c层面对非主库的 ATTACH 清掉该 flag——是否合适请维护者定夺(比如 checkpoint 语义是否依赖 ATTACH 库的只读主 fd)。附带观察(供排查发布构建,非本 issue 主体)
Android Maven AAR(
com.tencent.wcdb:main:2.1.15)实测不复现此问题:运行中进程/proc/<pid>/fdinfo显示主库 fd 为O_RDWR,同库VACUUM INTO成功。但对该 AAR 的libWCDB.so反汇编显示AbstractHandle::open的0x100006调用与unixOpen的 bit-20 掩码逻辑都在,且无任何清位指令——即发布的 Android 二进制运行时行为与 v2.1.15 tag 源码语义不一致(iOS 从同一 tag 源码构建则忠实复现 bug)。供团队排查 Android 发布管线时参考。