#1555·wcdb

VACUUM INTO fails with SQLITE_IOERR_LOCK/EBADF on any already-WAL database: vacuum dest inherits SQLITE_OPEN_MAINDB_READONLY

Author: ra1njCreated Aug 6, 2026Updated Aug 14, 2026

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 EBADFSQLITE_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 源码构建复现;masterAbstractHandle.cppsqlcipher submodule pin(f049bed)与 tag 相同,机制仍在)
  • iOS 真机 + 模拟器均 100% 复现

根因链

  1. 常规 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。
  2. flag 存活到 ATTACH:openDatabase 的 strip 清单不含该位 → db->openFlags 保留它 → attachFuncflags = db->openFlags(attach.c:147)→ VACUUM INTO 内部的 ATTACH %Q AS vacuum_db(vacuum.c:214)把 flag 传给了目标文件。
  3. O_RDONLY|O_CREAT:unixOpen(os_unix.c)对该 flag 清 isReadWrite保留 isCreate → 目标文件以 O_RDONLY|O_CREAT 创建成功(文件建出来了,fd 却只读;上游 stock SQLite 的 assert(isCreate==0 || isReadWrite) 在 fork 里被注释掉了)。
  4. EBADF:ATTACH 读空 schema 成功(F_RDLCK 在只读 fd 上合法);VACUUM 拷贝开写事务拿 RESERVED 锁 → fcntl(F_WRLCK) 打在只读 fd 上,Linux/Darwin 内核都强制返回 EBADFsqliteErrorFromPosixError(EBADF, SQLITE_IOERR_LOCK):
Code:IOError  ExtCode:3850 (SQLITE_IOERR_LOCK)  Message:"disk I/O error"  SystemErrno:9 (EBADF)
  1. 为什么常规测试测不到(伪阴性):全新库首个连接的 BasicConfig 要执行 PRAGMA journal_mode=WAL(写主库)→ 在只读主库 fd 上失败 → 触发 InnerHandle::open 的回退(enableWriteMainDB(true) + 重开)→ 该连接全程可写,VACUUM INTO 成功。所以"新建库 → 立即 VACUUM INTO"的测试全部通过;而已是 WAL 的库(真实用户库的第二个 session 起)100% 失败。

复现步骤

  1. 用 WCDB 打开一个库,写入数据(库转为 WAL),关闭;
  2. 重新打开同一个库(此时已是 WAL,可写重开回退不再触发);
  3. 任意 handle 执行 VACUUM INTO '/some/new/path.db'(如 Android handle.preparedWithMainStatement(...));
  4. → 恒定 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::open0x100006 调用与 unixOpen 的 bit-20 掩码逻辑都在,且无任何清位指令——即发布的 Android 二进制运行时行为与 v2.1.15 tag 源码语义不一致(iOS 从同一 tag 源码构建则忠实复现 bug)。供团队排查 Android 发布管线时参考。