CC Switch 把行号游标换成字节游标:什么算增量读

2026-08-31

有一类代码看上去已经”增量”了,其实一点都不增量:文件从头读到尾,读到第 N 行之前一律 continue,从第 N+1 行开始才干活。省掉的只是 JSON 解析,磁盘 IO 一个字节都没少。更麻烦的是,这种写法通常还夹带一个不容易发现的数据丢失:如果扫描发生的那一刻,日志文件的最后一行只写了一半,游标照样会把这一行算作”已处理”,等它补全之后,再也没有人会回头读它

CC Switch 在 v3.20.1 里把 Claude 会话日志的扫描游标从行号换成了字节偏移,顺手把上面这两件事一起解决了。这篇就顺着源码把这次改动拆开看:旧写法错在哪一行、新写法把游标推进的时机挪到了哪里、以及为什么作者宁可永久丢掉一部分历史记录,也不肯让扫描器”重读一遍”。

先说清楚本文的依据

本文对应的快照是 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31,对照用的上一版快照是 v3.19.2 之后的 c39c9032。所有结论都来自静态读这两个快照的源码与文档,我们没有编译、没有运行、也没有安装过这个桌面应用,因此下文不会出现任何关于界面、操作过程或速度体感的描述。凡是提到行号的地方,都是这两个快照当时的行号,随版本会漂。

旧写法:游标是行号,文件还是整读

先看 v3.19.2 那一版。src-tauri/src/services/session_usage.rs 里负责单文件同步的函数,主循环长这样(旧快照 session_usage.rs:253-300 一段):

let reader = BufReader::new(file);
let mut line_offset: i64 = 0;
for line_result in reader.lines() {
    line_offset += 1;
    // 跳过已处理的行
    if line_offset <= last_offset {
        continue;
    }
    let line = match line_result {
        Ok(l) => l,
        Err(_) => continue, // 容忍不完整的最后一行
    };
    ...
}

三个问题,都能直接读出来,不需要推断。

第一,游标是行号,所以文件必须整读。 reader.lines() 从字节 0 开始迭代,“跳过”发生在解析之后、入库之前。文件多大就读多少,跳过的行同样要被完整读出来才能数得清是第几行。

第二,line_offset += 1 发生在完整性判断之前。 这是半行 bug 的根。迭代器吐出一项,行号就先加一,之后才轮到 match line_result 去判断这一行读得完不完整;而写回游标的动作在函数末尾(旧快照 session_usage.rs:425update_sync_state(db, &file_path_str, file_modified, line_offset)),写的正是这个已经被加过的 line_offset。于是:扫描发生时最后一行只写了一半 → 行号被推过去 → 下一轮从下一行开始 → 这半行补全后的完整内容,永远不会被导入。注释里那句”容忍不完整的最后一行”描述的是不 panic,不是不丢。

第三,读错误被静默吞掉。 Err(_) => continue 意味着 IO 出错时既不上报也不中断,外层只会看到”同步完成”。

顺带一提,旧版写游标的 SQL 只有四列(旧快照 session_usage.rs:483 附近):file_pathlast_modifiedlast_line_offsetlast_synced_at。整张表里没有任何字节位置的概念。

新写法:游标结构先扩了两个可空字段

v3.20.1 的游标结构在 src-tauri/src/services/session_usage.rs:65-74

pub(crate) struct SyncCursor {
    pub last_modified: i64,
    pub last_line_offset: i64,
    pub last_byte_offset: Option<i64>,
    pub last_tail_fingerprint: Option<i64>,   // 仅 Claude 路径写入
    pub last_synced_at: i64,
}

两个新字段都是 Option,语义写死在建表注释里(src-tauri/src/database/schema.rs:299-306):last_byte_offset 为 NULL 表示”尚无字节游标”,此时回退全量读;last_tail_fingerprint 为 NULL 表示”无指纹可校验”,按纯追加处理。落到数据库上是一次 schema 迁移,新增两个可空列,迁移实现在 schema.rs:1588-1600,只做两次 add_column_if_missing;版本常量在 src-tauri/src/database/mod.rs:56

去看 schema.rs:299 时注意别读错:那行注释开头的”18.”是建表顺序的序号(前面是 17.、后面是 19.),不是 schema 版本号,两个数值撞车纯属巧合。

关键改动:游标只在换行符之后推进

主读循环换成了按字节读(session_usage.rs:506-524):

if buf.ends_with(b"\n") {
    push_committed_tail(&mut tail_buf, &buf);
    committed_offset += read as i64;
} else {
    incomplete_tail = true;
}

这段代码把”读到了”和”认了”拆成两件事。没有以 \n 结尾的尾段仍然会继续往下解析、照常导入(重复由 request_id 主键去重挡住),但 committed_offset 不动,同时置上 incomplete_tail。下一轮从原位置重新读,补全后的完整行会被再确认一次。

对照着看就很清楚:旧版是”读到一项就算一行”,新版是”看见换行符才算一行”。同样一个半行场景,前者丢数据,后者最多重复解析一次然后被去重挡掉。这个行为被两条回归测试钉死,在 session_usage.rs:1224:1261

至于起始位置,增量路径不再从字节 0 开始。session_usage.rs:430-473 在读之前先做一次判定:

let truncated = !(0..=file_size).contains(&offset);
let seed = if truncated { None } else { Some(read_tail_before(&mut file, offset)?) };
let rewritten = match (&seed, last_fingerprint) {
    (Some(seed), Some(expected)) => claude_tail_fingerprint(seed) != expected,
    _ => false,
};

这里有个挺讲究的设计:read_tail_beforesession_usage.rs:362-372)读完之后,文件位置恰好停在 offset,所以这一次”为了校验指纹而读”同时完成了 seek 动作,读到的字节还直接当作滚动尾部缓冲的种子。纯追加的快路径因此不产生任何额外 IO——这句话在提交 f8d97348 的说明里也写了。指纹本身是 SHA-256 取前 4 字节转 i64,覆盖游标边界前的一段尾部字节,长度常量在 session_usage.rs:347;需要它的原因写在 session_usage.rs:397-400:截断可以靠”游标超过文件大小”发现,但同尺寸或更大的替换靠文件大小检测不出来

存量用户怎么办:旧行号游标就地换算

数据库里躺着的是行号,代码却要按字节 seek,这中间需要一次转换。实现在 session_usage.rs:488-500:从字节 0 开始 read_until(b'\n') 逐行推进,只数字节,不解析,不导入,数满原来那 L 行就停,此时的 committed_offset 就是等价的字节位置。

注释(:482-487)交代了两个边界,都是容易写错的地方:

  • 旧的 lines() 会把没有换行的尾段也计作一行,而 read_until 每次返回同样计一行,两边的口径因此精确对齐;
  • 转换中途一旦出错必须整文件失败。若照常落库,last_line_offset 会被清 0、剩余待跳的行数就丢了,下一轮会把旧代码已经导入过的行当成新行重导一遍。

两条对应测试在 session_usage.rs:1422(转换不重复导入)与 :1479(旧游标超过文件末尾时不导入任何东西)。导入与游标推进包在同一个事务里(session_usage.rs:623-684),写游标的 SQL 在 :709-714,其中 last_line_offset 固定写 0,注释说明这是明确表示”行号游标不可用”。

为什么宁可少算也不重放

检出截断或改写之后,处理方式不是回退重读,而是重算一次当前 EOF 前的指纹、把游标直接钉到 file_size,然后返回,imported = 0,只带一个 pinned_rewrite 原因(“截断”或”重写”)。理由写在 session_usage.rs:390-395:明细表里 30 天前的条目会被汇总后剪掉,而 request_id 去重只查明细表,对已剪掉的条目失明;一旦全量重读,这些旧条目会在下次汇总时被二次累加,统计被永久放大。同一条约束还出现在 session_usage.rs:76-81(因此整表游标预取失败必须中止本轮,不能回退成空表)与 :397-400

代价写得也很直白:旧行号游标半行 bug 吞掉的那些历史行找不回来了——在没有持久账本的前提下,它们和”已被剪掉的旧条目”无法区分,而双算比丢行更糟。CHANGELOG 的升级说明里同样有这一条(CHANGELOG.md:52)。这不是含糊其辞,是一次在源码注释、提交说明和发布文档三处措辞一致的显式取舍。

配套地,三种异常被刻意分成了三类:incomplete_tail 计入 deferred_files、不进错误列表(下一轮自愈);read_error 两边都算(会重试,但用户要知道);pinned_rewrite 只进错误列表、不计入 deferred_files——因为它永远不会有下一轮。

那条性能数字,不能当作可复现结果引用

v3.20.1 的发布说明里有一句”12 MB 活跃会话文件从整读 6.04 秒降到增量 9.3 毫秒”。这是改动自带的基准记录,来自作者本机的一次性测量,被写进了提交说明并转述进文档。但从快照出发无法复现src-tauri/ 下没有 benches/ 目录,src-tauri/Cargo.toml 里既没有 [[bench]] 段也没有相关依赖,src-tauri/ 下没有任何 .rs 文件含这个数字,整仓搜一遍,真正提到它的只有 CHANGELOG 与三语发布说明(另有一个图标数据文件 src/icons/extracted/index.ts 的 SVG 路径里恰好含同样的字串,与这个基准无关)。同一份文档里另有一组数字(本版的提交数、文件数、增删行数)是可以用一条 git 命令重算出来的——能验的和不能验的就并排放在同一页上,引用时得自己分清楚。

想自己去看这条修复线,可以在仓库里直接翻这几个提交:092ea1f3(扫描模式开关)、bcee61be(字节游标增量扫描)、f8d97348(非追加改写检测)、f05e2033(跳过上报)。它们由同一位维护者在 2026-08-27 一晚内完成,git show --stat <hash> 就能看到各自的改动面。数版本区间的提交数时记得带口径:git log --oneline v3.19.2..v3.20.1 与”上一个 tag 到发版提交的父提交”这两种数法结果不一样,只写数字不写口径没法复核。

最后回到标题那个问题。什么算增量读?判据不是”跳过了多少条”,而是下一轮的第一个字节从哪里开始读,以及游标凭什么敢往前推。旧写法两条都不满足:起点永远是 0,推进条件是”迭代器吐了一项”。新写法把起点交给字节偏移,把推进条件收紧成”看见了换行符”,剩下的复杂度——指纹、转换、钉 EOF——全是为守住这两条付出的代价。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、 路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理, 核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。