Skip to content

fix(session-store): 修复 SQLite 导入孤儿 WAL - #1073

Merged
deepcoldy merged 3 commits into
deepcoldy:masterfrom
le0tan:fix/session-store-wal-import
Aug 29, 2026
Merged

fix(session-store): 修复 SQLite 导入孤儿 WAL#1073
deepcoldy merged 3 commits into
deepcoldy:masterfrom
le0tan:fix/session-store-wal-import

Conversation

@le0tan

@le0tan le0tan commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

变更

首次将 JSON 会话导入 SQLite 时,临时库显式改用 DELETE journaling(不再 WAL):关闭后主库已自包含,单次 renameSync 即可完整发布。同时把导入路径三处 sidecar 循环统一到实际日志模式:pre-import 清理、post-close 守卫、error-path 清理都覆盖 .tmp-journal,并保留 -wal/-shm 以清理旧版本崩溃残留。

新增真正在 Bun 1.4.0 下运行生产导入路径的回归测试。测试通过统一的 test/helpers/ts-runner.ts 定位并启动 Bun,子进程自报运行时和版本,导入 40 行后重新读取,并断言没有任何 .tmp-* sidecar。

原因

Bun 1.4.0 下,导入路径的 prepared statement 尚未 finalize 时调用 close(),WAL 不会在关闭时完成 checkpoint。随后只重命名主库,-wal/-shm 仍保留 .tmp basename,schema 和已提交行被留在孤儿 WAL 中;发布后的 sessions.db 重开时会报 SQLite disk I/O error 或呈现为空库。

DELETE 模式使一次性构建产物在提交后由主文件自包含,不依赖 WAL checkpoint 和多文件同步 rename。live store 打开后仍按既有逻辑切到 WAL。

影响范围

仅改 JSON 首次导入的 SQLite 初始化路径及其回归测试;其他 CLI、后端及话题/群组会话路径没有改动。本 PR 阻止新坏库产生,不处理旧版本已经发布的损坏库;存量恢复另行跟进。

验证

  • bun run build:通过
  • bun run test -- test/session-store-sqlite-bun-import.test.ts test/session-store-sqlite.test.ts:2 个文件、20/20 通过
  • 变异验证:仅把导入临时库改回 WAL,新回归稳定失败,Bun 子进程读回 0/40
  • 子进程断言实际运行时为 Bun,且 Bun.versionpackageManagerbun@1.4.0 一致
  • bun run daemon:restart:通过

@le0tan
le0tan requested a review from deepcoldy as a code owner August 28, 2026 19:26
@le0tan
le0tan force-pushed the fix/session-store-wal-import branch from ac852cc to ede2188 Compare August 28, 2026 19:34
@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(以维护者审阅为准)

先说结论:根因判断的方向是对的,修法(WALDELETE)实测有效,我建议合入前补一处测试。下面是我实际跑出来的证据。

一、缺陷与修复我独立复现了,成立

在 Bun 1.4.0 上跑真实生产代码(session-store.ts 的导入路径),PRE/POST 对照:

发布后 sessions.db 孤儿 sidecar 新进程重开
master(WAL) 4096 字节(只有文件头) .tmp-wal 20632B、.tmp-shm 32KB disk I/O errorlistSessions() = 0
本 PR(DELETE) 32768 字节 40/40 行读回正常

同一脚本在 Node 下两种模式都正常 —— 确认是 Bun 特有。影响范围 里「live store 仍走 WAL」我也验了:导入完成后对已发布库查 PRAGMA journal_modewal,符合描述。

-journal 那三处清理也站得住:DELETE 模式下 -journal 只在事务进行中存在(实测 12824B),commit 后即消失 —— 所以它只会在「导入中途崩溃」时残留,正是清理循环该覆盖的场景。

二、🔴 唯一的合入前建议:新增的断言目前没有区分力

新测试里那三条 .tmp-journal/.tmp-wal/.tmp-shm 不存在 + imported.live.title 的断言,在缺陷存在时也是绿的。两组变异:

  • MUT-A:只把 journal_mode = DELETE 改回 WAL(保留 PR 的其它改动)→ 19/19 全绿
  • MUT-B:把 src/services/session-store.ts 整个文件回退到 master → 19/19 全绿

原因是结构性的:vitest 的测试体永远在 Node 下执行,即使用 bun x vitest 启动(我用子进程自报运行时验过:node v22.21.1)。而这个缺陷只在 Bun 下出现,所以断言从原理上看不到它。CI 侧同样盖不到 —— ci.yml 自己的注释就写着「vitest runs the Node path」,而 bun-binary job 跑的 smoke-bun-binary.mjs 用空 bots.json,不会触发任何导入。

这意味着:修复本身是真的,但它没有回归护栏 —— 未来任何人把这行改回 WAL,全套测试和 CI 都会放行。

可行的补法(我写了一版,实测在 PR 代码上绿、MUT-A/MUT-B 两组变异都红):显式定位 bun 二进制起子进程跑真实导入路径,并让子进程自报运行时、断言它确实是 bun(否则守卫又会变空转):

// 关键:不能用 process.execPath / ts-runner —— 它们继承父运行时(Node)
function findBun(): string | null {
  for (const dir of (process.env.PATH || '').split(':')) {
    const cand = join(dir, 'bun');
    if (dir && existsSync(cand)) return cand;
  }
  return null;
}
// …spawnSync(bun, ['-e', src]),src 内 import 生产 session-store 并 init/listSessions
expect(res.runtime, 'child must really run under Bun').toBe('bun');  // 自证有牙
expect(res.visible).toBe(40);

若不希望测试依赖 bun 存在,退一步也可以只加形态守卫(grep 生产源码断言导入路径不出现 journal_mode = WAL)—— 比现状强,但不如上面端到端。

三、🟠 一个 PR 描述外的发现:已被打坏的存量库不会自愈,且会静默报「健康」

这条不是本 PR 引入的,但它决定了修复的实际覆盖面,建议一并考虑:

如果某个库已经被旧代码打坏(.db 只有 4096 字节 + 孤儿 .tmp-wal 仍在),本 PR 的代码对它无能为力,因为 pre-import 清理只在 .db 不存在时才跑(load()if (!existsSync(dbFp) && …)),而这种库 .db 是存在的。实测结果:

{"visible": 0, "leftover_tmp": ["sessions.db.tmp-shm","sessions.db.tmp-wal"]}
listSessionsStrict() → 返回成功(该库被当作 HEALTHY)
frozen JSON 仍有 40 行

40 行会话静默变成 0 行,而且 listSessionsStrict() 不抛 —— 它只在 loadFailure 时抛,而这里库能打开、表能查、就是空的。旁边冻结的 JSON 里 40 行数据完好无损却不会被回读(导入被 .db 已存在挡住)。

补充两点让严重性更准确:

  • 这个「永久坏」状态需要发布后到下次打开之间进程没有干净退出(我用 SIGKILL 复现)。若进程干净退出,Bun 会在退出时结算,下次重启 SQLite 能凭同名 WAL 恢复,看到 40 行 —— 所以并非每次都永久损坏。
  • 当前线上 fleet 跑的是 Node(~/.botmux/bin/botmuxexec node …,56 个 store 扫下来没有 4096 字节的库、也没有任何 .db.tmp* 残留),所以存量数据目前没有受影响。风险面是编译版/Bun 形态。

顺带一提:PR 新增的 post-close 守卫本身是个有效的安全网。我把它和 WAL 组合起来测(WAL + 守卫),它会拒绝发布、保留冻结 JSON 的 40 行,且 listSessionsStrict() 正确抛 SessionStoreUnavailableError —— fail-closed 语义是对的,值得保留。

四、其它核对结果

  • bun x tsc --noEmit:0 错
  • test/session-store-sqlite.test.ts + test/session-store-bwrap.test.ts 单独跑:20/20 绿
  • 全量 unit 跑了三次,失败集在漂:8 文件/21 用例 → 9/22 → 而「PR 分支回退掉自身改动」的干净基线是 11 文件/24 用例(比 PR 更多)。失败文件是 workflow-climojo-*whiteboard-clipreset-export-cli 这类,没有一条与 session-store / sqlite 相关,且基线失败数更高 —— 结论是既有+偶发,本 PR 没有引入新失败。PR 里提到的 plugin-mcp-sandbox 属于这一类
  • 其它 db.close() 调用点都不做「close 后 rename」,不受影响

五、一个可选的措辞修正

描述里说「WAL 本就不适用于会被连同 sidecar 一起 rename 的库」——方向对,但我实测根因更具体一点:并非 WAL 天生不能配 rename,而是 Bun 的 close() 在还有活着的 prepared statement 时不做 checkpoint。同一段代码:

  • db.close()(当前写法,insert 语句仍活着)→ main 4096B + 孤儿 WAL
  • stmt.finalize() 后再 close() → main 24576B,无 sidecar
  • db.close(true)(throwOnError)→ main 24576B,无 sidecar

Node 三种写法都正常。这不影响你选 DELETE(对一次性产物来说 DELETE 确实更合适,也更不依赖引擎细节),但如果描述里写准根因,将来读到这段的人不会误以为「WAL + rename」本身是错的。


以上是自动评审的初步意见,最终以维护者审阅为准。第二节(补一个真在 Bun 下跑的回归)是我唯一建议合入前处理的项;第三节更像是可以另开 issue 的既有问题。

@deepcoldy

Copy link
Copy Markdown
Owner

复审意见(第二位自动评审,以维护者审阅为准)

上面这条初审的三个核心结论我已独立复核,全部成立;补充几点校准与实现要求。

1. 缺陷与修复:确认成立

Bun 1.4.0 + master 代码跑真实导入路径复现:发布后 sessions.db 仅 4096B、孤儿 .tmp-wal 残留、同进程 attach 即 disk I/O error;本 PR 代码下 40/40 行读回。DELETE 修法与 post-close 守卫均验证有效。

2. 「现有断言无区分力」确认;补回归时请满足四点

独立证实:bun x vitest 下测试体跑在 Node(process.execPath 指向 node,子进程自报 node v22.21.1),现有断言原理上看不到 Bun 特有缺陷。同意补「真在 Bun 下跑的端到端回归」,实现上:

  • 找不到 bun 时必须 fail(或 env 显式 escape hatch),不能静默 skip。好消息是 CI 覆盖没问题:build job 用 setup-bun@v2 pin 了 1.4.0,PATH 里有 bun,PATH 扫描在 CI 上找得到;但若写成「找不到就 skip」,将来 runner/setup-bun 变化时守卫会无声消失——与被批评的现状同构。
  • PATH 扫描是对的,别用 login shell:实测 bash -lc 'which bun' 在 fnm 环境下反而找不到 bun;spawnSync(bun, …) 直接继承 vitest 进程 env 即可,不要 shell: true
  • 子进程自报 Bun.version 并断言与 package.jsonpackageManager 一致:机器上可能有多份 bun(实测本机 ~/.bun/bin/bun 与 fnm 路径下各一份);且 Bun 下 process.version 报的是仿 Node 版本(实测 v26.3.0),识别运行时必须用 Bun global,不能用 process.version
  • 断言除行数读回外,也断言无 .tmp-* sidecar 残留(existsSync 顺手可加 X_OK 检查)。

另注:若未来有人改用「WAL + stmt.finalize() 后 close」的修法,该回归会放行——这是正确语义(该修法实测也有效),DELETE 仍是更不依赖引擎细节的选择。

3. 存量坏库:结论确认,机制可写得更准,窗口其实更宽

复现了「rename 后 SIGKILL → 永久损坏:重启 visible=0listSessionsStrict() 不抛、冻结 JSON 40 行完好」。两点补充:

  • 自愈机制:干净退出能恢复,是因为 Bun 在进程退出时对未完全关闭的连接做 checkpoint——沿 fd 写入,而 fd 已指向被 rename 的 inode(实测干净退出后 .tmp-wal 清零、sessions.db 长到 32768B、数据回来)。因此永久损坏的触发条件是「发布后到进程干净退出之间发生 crash/SIGKILL/断电」,比「下次打开之前」更宽。
  • 第一现场并非完全静默:发布后同进程 attach 立刻报 disk I/O error(fail-closed,strict 抛);静默变 0 行的是重启后的新进程。且孤儿 .tmp-wal 仍带全量数据留在盘上,手工可救。

严重性校准(需 crash 才永久坏、线上 fleet 跑 Node 未受影响、风险面是 Bun/编译形态)与初审一致,同意。

4. 根因归因:独立复现成立,建议按此修 PR 描述措辞

Bun 1.4.0 四种写法对照(同一 schema + 40 行插入):

写法 发布后 main sidecar
prepared stmt 未 finalize + close()(导入路径现状) 4096B WAL 173KB 残留
stmt.finalize()close() 数据完整
close(true) 数据完整
exec 插入 + close() 数据完整

Node 2.x 四种全部正常。根因确为「Bun 的 close() 在还有未 finalize 的 prepared statement 时不做 checkpoint」。

结论

同意初审判定:修法正确,唯一合入前建议是补一个真在 Bun 下跑的端到端回归(要求见第 2 节);PR 描述的根因措辞建议一并修正。最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审补充:可直接取用的回归测试补丁(以维护者审阅为准,不代表合并决定)

接前一条意见的第二节。既然「修复是真的、但没有护栏」这条只有在真的跑 Bun 时才能咬住,我把测试写好并验证过了,贴在这里供你直接取用或改写。新增文件 test/session-store-sqlite-bun-import.test.ts(不改动你现有的测试文件):

展开补丁
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

/**
 * The orphan-WAL defect this guards is BUN-ONLY, and a vitest test body always
 * runs under NODE — even when the suite is launched with `bun x vitest`
 * (verified: the body reports `node v22.21.1`). So an in-process assertion can
 * never observe it: reverting the production fix leaves the Node-side suite
 * 19/19 green. CI cannot see it either — `bun run test` runs the Node path, and
 * the `bun-binary` job's smoke uses an empty `bots.json`, so no import happens.
 *
 * Therefore this guard spawns a REAL bun binary and drives the real import path.
 * Two things keep it from silently degrading into a no-op:
 *   • the child reports its runtime and `Bun.version`, and we ASSERT both — a
 *     child that quietly fell back to Node would otherwise pass vacuously;
 *   • a missing bun FAILS rather than skips (opt out explicitly with
 *     BOTMUX_ALLOW_NO_BUN=1). A skip would let the guard vanish the next time
 *     the runner image changes — the same silent-loss failure mode the fix is
 *     about.
 */

/** `packageManager` pins the Bun the project (and CI's setup-bun) uses. */
function pinnedBunVersion(): string {
  const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) as { packageManager?: string };
  const m = /^bun@(.+)$/.exec(pkg.packageManager ?? '');
  if (!m) throw new Error(`package.json packageManager is not a bun pin: ${pkg.packageManager}`);
  return m[1];
}

/**
 * Resolve bun from the INHERITED PATH. Deliberately not `process.execPath`
 * (that is the parent runtime — Node here) and not a login shell: `bash -lc
 * 'command -v bun'` exits 1 on a machine where bun lives under an fnm
 * node-versions dir, because the login PATH differs from the one vitest runs
 * with. Not checking the executable bit — an unusable candidate surfaces as a
 * spawn failure, which is louder than a silent skip.
 */
function findBun(): string | undefined {
  for (const dir of (process.env.PATH ?? '').split(':')) {
    if (!dir) continue;
    const candidate = join(dir, 'bun');
    if (existsSync(candidate)) return candidate;
  }
  return undefined;
}

describe('first-start JSON import under Bun', () => {
  it('publishes a store a fresh Bun process can read back in full', () => {
    const bun = findBun();
    if (!bun) {
      // Explicit escape hatch only; absence is a failure by default.
      if (process.env.BOTMUX_ALLOW_NO_BUN === '1') return;
      throw new Error(
        'bun not found on PATH — this guard covers a Bun-only defect and must not silently skip. '
        + 'Set BOTMUX_ALLOW_NO_BUN=1 to opt out deliberately.',
      );
    }

    const dataDir = mkdtempSync(join(tmpdir(), 'sqlite-import-bun-'));
    // Isolate HOME too: without SESSION_DATA_DIR the store falls back to
    // HOME/.botmux, so a regression in the harness would touch real data.
    const fakeHome = mkdtempSync(join(tmpdir(), 'sqlite-import-home-'));
    try {
      const rows: Record<string, unknown> = {};
      for (let i = 0; i < 40; i++) {
        rows[`s${i}`] = {
          sessionId: `s${i}`, chatId: 'oc_chat', rootMessageId: `om_s${i}`, title: `t${i}`,
          status: 'active', createdAt: '2026-01-01T00:00:00.000Z', scope: 'topic',
        };
      }
      mkdirSync(dataDir, { recursive: true });
      writeFileSync(join(dataDir, 'sessions-appA.json'), JSON.stringify(rows));

      const storeModule = join(process.cwd(), 'src', 'services', 'session-store.ts');
      const source = `
        const store = await import(${JSON.stringify(storeModule)});
        store.init('appA');
        console.log(JSON.stringify({
          runtime: typeof Bun !== 'undefined' ? 'bun' : 'node',
          bunVersion: typeof Bun !== 'undefined' ? Bun.version : null,
          visible: store.listSessions().length,
        }));
      `;
      const child = spawnSync(bun, ['-e', source], {
        encoding: 'utf-8',
        env: { ...process.env, HOME: fakeHome, SESSION_DATA_DIR: dataDir },
      });

      const line = (child.stdout ?? '').split('\n').filter(l => l.trim().startsWith('{')).pop();
      expect(line, `bun child produced no result. stderr:\n${child.stderr}`).toBeTruthy();
      const result = JSON.parse(line!) as { runtime: string; bunVersion: string | null; visible: number };

      // Self-certification: without these the guard passes even if the child
      // silently ran under Node, where the defect does not exist.
      expect(result.runtime, 'child must actually run under Bun').toBe('bun');
      expect(result.bunVersion, 'child bun must match the packageManager pin').toBe(pinnedBunVersion());

      // The defect: schema+rows stranded in an orphan WAL, so the published
      // .db opens as an empty (or unreadable) database.
      expect(result.visible, 'every imported row must be visible under Bun').toBe(40);

      // Nothing may remain under the `.tmp` basename after publishing.
      const storeDir = join(dataDir, 'session-stores', 'appA');
      expect(readdirSync(storeDir).filter(f => f.includes('.tmp'))).toEqual([]);
    } finally {
      rmSync(dataDir, { recursive: true, force: true });
      rmSync(fakeHome, { recursive: true, force: true });
    }
  });
});

我对这版补丁跑过的验收(都是最终版,不是原型)

检查 结果
你的分支原样 1/1 绿
MUT-A:只把 journal_mode 改回 WAL every imported row must be visible under Bun: expected +0 to be 40
MUT-B:session-store.ts 整个回退到 master (同一条断言)
PATH 里没有 bun ,报 bun not found on PATH — … must not silently skip
同上 + BOTMUX_ALLOW_NO_BUN=1 绿(显式豁免)
packageManager 改成 bun@9.9.9 — 版本 pin 断言有牙
bun x tsc --noEmit exit 0
与你的 session-store-sqlite.test.ts 一起跑 20/20 绿,4.06s
真实数据目录是否被污染 未被触碰(store 数 56→56、无 appA);临时目录 finally 清净

几个设计取舍,供你判断是否同意

  • 找不到 bun 默认 fail 而非 skip。skip 的话,将来 runner 镜像一变守卫就无声消失 —— 那正是这个 PR 要修的那类「静默失效」。要豁免请显式 BOTMUX_ALLOW_NO_BUN=1。CI 上不用担心:build job 有 setup-bun@v2 + bun-version: 1.4.0,PATH 找得到。
  • 子进程必须自报运行时并断言 runtime === 'bun'。少了这条,子进程一旦静默退化成 Node,测试会「通过」——因为缺陷在 Node 下本来就不存在。⚠️ 另外注意 Bun 下 process.version 会报 v26.3.0(仿 Node),所以判运行时只能用 Bun global,不能用 process.version
  • 断言 Bun.version === packageManager 的 pin,把守卫绑死在 CI 用的那个 Bun 上,避免本机多份 bun 版本漂移时结论不可比(我这台机器 PATH 上有 6 个 bun 路径)。
  • 用 PATH 扫描定位 bun,不用 process.execPath、也不用 login shellprocess.execPath 是父运行时(在 vitest 下就是 Node);而 bash -lc 'command -v bun' 在我这台机器上 exit 1(bun 装在 fnm 的 node-versions 目录下,登录 shell 的 PATH 与 vitest 继承的不同)。
  • 同时隔离 HOMESESSION_DATA_DIR:只设后者的话,一旦 harness 出问题会 fallback 到 HOME/.botmux 碰真实数据。
  • 断言的位置:init() 是惰性的,导入发生在首次 listSessions(),所以 .tmp-* 检查必须放在那之后 —— 我实测过放前面会读到 ENOENT,那条断言就成了空转。

另外,visible === 40 是真正咬住缺陷的那条;.tmp-* 那条是附加防线(两个变异里它也会脏,只是 visible 先失败)。

以上是自动评审的补充材料,是否采用、怎么改都以维护者判断为准;我不会代为合并。

le0tan added 3 commits August 29, 2026 16:16
导入临时库现用 DELETE journaling,其 sidecar 是 `.tmp-journal`。
将 pre-import 清理、post-close 守卫、error-path 清理三处循环补上
`-journal`,使不变量与实际使用的日志模式一致;回归断言同步校验
`sessions.db.tmp-journal` 不残留。
@le0tan
le0tan force-pushed the fix/session-store-wal-import branch from dcd3bc6 to 19cbb44 Compare August 29, 2026 08:18
@le0tan

le0tan commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

已按建议处理,更新在 19cbb445(分支也已 rebase 到最新 master):

  • 新增 test/session-store-sqlite-bun-import.test.ts,通过统一的 test/helpers/ts-runner.ts 显式定位并启动 Bun;找不到 Bun 直接失败,不静默 skip。
  • 子进程同时断言 runtime === "bun"Bun.version === packageManager pin,导入 40 行后由真实生产路径重新读取,并检查无任何 .tmp-* sidecar。
  • 做了 WAL 变异验证:只把 DELETE 改回 WAL 后,新测试稳定失败,读回 0/40;恢复后与原 SQLite 专项测试合计 20/20 通过。
  • PR 描述已按实测机制修正:根因是 Bun 1.4.0 在 prepared statement 未 finalize 时 close() 未完成 WAL checkpoint,不再笼统归因为 WAL 与 rename 天生不兼容。
  • 已确认存量坏库不会由本修复自动恢复;这部分不扩大当前 PR 范围,后续单独处理恢复/检测策略。

验证:bun run build、上述 20 个测试、bun run daemon:restart 均通过。

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审复审意见(新 commit 19cbb445,以维护者审阅为准)

结论:此前提出的唯一合入前阻断项已闭合,我这边没有新的阻断意见。 下面是我实际跑出来的验证。

一、新 commit 解决了什么(白话)

上一轮的问题是:修复本身是真的,但没有护栏 —— 因为 vitest 的测试体永远跑在 Node 上(即使用 bun x vitest 启动),而这个缺陷只在 Bun 下出现,所以把修复整个撤掉,全套测试和 CI 都会放行。

这次新增的 test/session-store-sqlite-bun-import.test.ts 补上了这道护栏:它显式找到一个真的 bun 二进制并起子进程,在子进程里 import 真实的 session-store.ts、跑一次真实导入、再读回来。子进程自报运行时和版本,父进程断言它确实是 bun、且版本与 packageManager 的 pin 一致 —— 这两条自证是关键,否则子进程一旦悄悄退化成 Node,测试会"通过"(因为缺陷在 Node 下本来就不存在)。

配套在 test/helpers/ts-runner.ts 里加了两个共用导出:resolveBunExecutable()(扫 PATH 找 bun)和 spawnSyncBunTsEvalWithRepoImports()(用它起子进程)。放进共用 helper 而不是散在单个测试里,是合理的 —— 以后再有 Bun 专属回归可以直接复用。

二、我跑过的验证

检查 结果
新测试在本分支 1/1 绿
MUT-A:只把 journal_mode 改回 WAL expected +0 to be 40
MUT-B:session-store.ts 整个回退到 master (同一条)
PATH 上没有 bun bun not found on PATH; cannot run the required Bun-specific regression(fail 而非 skip ✅)
packageManager 改成 bun@9.9.9 expected '1.4.0' to be '9.9.9' ⟹ 版本 pin 断言有牙
bun x tsc --noEmit exit 0
四个直接相关文件同跑(新测试 + session-store-sqlite + ts-runner-helper + session-store-bwrap) 27/27 绿
全量 unit 8 文件 / 21 用例失败 —— 与我上一轮量到的既有基线逐字一致,且无一与 session-store / sqlite / ts-runner 相关
真实数据隔离 store 数 56→56、无 appA、临时目录 finally 清净 ✅

三、两处你比我原来的写法更好

  1. accessSync(candidate, X_OK) 优于我用的 existsSync。我原来特意说"不查可执行位",理由是不可执行的候选会以 spawn 失败暴露。你这样更早失败、也更准。我担心过一件事并实测排除了:root 下 X_OK 是否会被绕过 —— 结论是不会,对 chmod 644 的假 bun 仍正确拒绝(Linux 要求至少一个 exec 位)。
  2. Windows 分支(bun.exe / bun.cmd)是我没考虑的,虽然本仓 daemon 跑 Linux,但共用 helper 里补上是对的。

顺带说明一点,免得后来人误改:新 helper 里硬写 ['-e', source]没有复用 tsEvalArgs(),这是对的tsEvalArgs()父进程运行时返回参数,父进程是 Node 时会返回 --input-type=module,语义上就不是"给 bun 用的"。(我实测 bun 其实也接受这个 flag,所以复用不会真的坏 —— 但按当前写法语义更清楚。)

四、非阻断的小建议(采不采都行)

  • BOTMUX_ALLOW_NO_BUN 之类的显式豁免被去掉了。默认 fail 我完全赞成(这正是核心);但如果将来有人在没装 bun 的环境跑单测,现在只能改代码。是否留一个显式 env 逃生阀,由你判断 —— CI 上没有这个问题,build job 有 setup-bun@v2 + bun-version: 1.4.0,PATH 找得到。
  • resolveBunExecutable / spawnSyncBunTsEvalWithRepoImportstest/ts-runner-helper.test.ts 里没有直连测试。它们已被新测试端到端用到(所以不是死代码),但那个 helper 文件本身的测试覆盖了其它每个导出,补两条会更一致。

五、PR 描述与实现的一致性

描述里「通过统一的 test/helpers/ts-runner.ts 定位并启动 Bun」「子进程自报运行时和版本」「断言没有任何 .tmp-* sidecar」——逐条核对,与代码一致。根因段落也按上一轮的实测改准了(说明是「prepared statement 尚未 finalize 时 close() 不做 checkpoint」,而不是笼统的「WAL 不适用于 rename」)。

「本 PR 阻止新坏库产生,不处理旧版本已经发布的损坏库;存量恢复另行跟进」——这个边界划得清楚且准确,与我实测一致(已损坏的库因为 .db 已存在,不会走 pre-import 清理分支)。


以上是自动评审的复审意见,最终以维护者审阅为准;我不代为合并。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

双审通过(自动评审 + 交叉复审),维护者确认合入。

修复经真跑验证有效,新增回归测试有牙(两组变异均转红),与最新 master 合并干净且护栏不失效。

@deepcoldy
deepcoldy merged commit 42f8d4e into deepcoldy:master Aug 29, 2026
3 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.18.7

deepcoldy added a commit that referenced this pull request Aug 29, 2026
#1073 只阻止产生孤儿 WAL 坏库,不修存量。被打坏的 store 主库仅 4096 字节空壳、数据全在
sessions.db.tmp-wal 里,而 listSessions() 返回 0 且 listSessionsStrict() 不抛 ——
静默丢掉全部会话且自报健康。

检测只用「.db 存在且 <dbFp>.tmp* 孤儿存在」(quick_check 在坏库上返回 ok,零区分力;
「表不存在」会被 CREATE TABLE IF NOT EXISTS 自掩蔽)。

恢复不就地改名 .tmp-wal(-wal 是替换而非合并语义,实测会把已被写入新会话的库净毁数据),
改为复制到 scratch 让 SQLite 重放后 INSERT OR IGNORE 合并,活行永远赢。

两个独立决策分开判:能否动手需正向作证(孤儿确有重放 / 快照被读到——看解析了哪个源不看行数 /
同 digest receipt);能否毁掉孤儿需 wal_checkpoint(PASSIVE) 真实接受帧数 + 与不挂 WAL 的
同一 shell 整行差分,证不了完整则归档原始字节而非删除。清理顺序把带数据的 .tmp-wal 放最后,
使「只剩 .tmp-shm」不可能产生;跨崩溃收敛用与合并同事务提交的 receipt。

无法证实时设 loadFailure 让 listSessionsStrict() 正常抛错;非 owner 进程不修库。恢复全程
在既有文件锁内。

18 例回归全部在真 Bun 子进程里造坏库并跑生产 load();反变异 14 组逐一转红;session-store
四文件 128/128;全量 19565 passed(5 条失败均为既有环境项或端口竞争,隔离重跑绿)。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants