Skip to content

fix(gitmount): 解锁窗口异常安全——ScopedUnlock、/commits 飞行守卫与回调边界兜底 - #13

Open
OrbitZore wants to merge 1 commit into
mainfrom
fix/exception-safety-unlock-windows
Open

OrbitZore wants to merge 1 commit into
mainfrom
fix/exception-safety-unlock-windows

Conversation

@OrbitZore

Copy link
Copy Markdown
Owner

修复 #4(异常安全:分段加载解锁窗口在分配失败时触发 mutex UB 与进程终止;/commits 生成异常使 single-flight 永久卡死)。

问题概述

src/gitmount.cpp 有 5 处手写 mu_.unlock() … mu_.lock() 窗口,窗口内的分配(std::string::assign / make_shared / push_back)大小可被仓库内容驱动(ODB 解压无大小上限),在内存压力 / ulimit -v / 并发 cache miss 下失败时:

  1. 异常展开使外层 std::lock_guard 对未上锁的 mutex 调 unlock()——C++/POSIX 双重 UB;
  2. 异常穿透 libfuse 纯 C 栈帧,全仓库无任何 handler → std::terminate → SIGABRT,挂载进程整体死亡,所有在途请求失败。

加重缺陷:open_commits 生成段先置 commits_generating_ = true 再 g.unlock();若生成段抛异常,flag 永不复位、epoch 不再前进、无 notify——后续所有 /commits open 在条件变量上永久阻塞,每个阻塞 open 占用一个 libfuse worker(fuse_loop_mt 默认上限 10),耗尽后整个文件系统(不只 /commits)失去响应。回调边界一旦加 catch,卡死即成为主导症状,因此两部分必须同批修复。

实现要点

  • ScopedUnlock RAII 守卫(新文件 src/lock_guards.hpp,GPL-3.0-or-later,仿现有头文件风格):构造解锁、析构重锁(含异常展开路径),不可拷贝。替换全部 5 处手写窗口:tree_locked、commit_facts_locked、load_blob_locked、cb_open 超大 blob 钉住、cb_open commits 生成。各窗口语义逐行不变(锁外读、锁内缓存重查/插入的顺序保持)。
  • SingleFlight + GeneratorFinish 飞行守卫(同文件):把 /commits 的 single-flight 状态机(mutex/cv/generating/epoch)抽成可单测的小类;GeneratorFinish 析构经同一 unique_lock 幂等重锁后 finish()(复位 flag、推进 epoch、notify_all),保证任意退出路径——包括异常——状态机复位。open_commits 重写为使用它,可见语义不变:fast path、搭车者醒来后 commits_buffer_ 有则共享(0)无则 -EIO、生成失败下次 open 重试(§3.5)。生成段包 try/catch: catch (...) { ok = false; }——bad_alloc 等任何异常映射为生成失败,不再逃逸出 open_commits。锁序不变:flight 内部 mutex 仍是最外层锁。
  • FUSE 回调边界兜底:cb_getattr / cb_readlink / cb_opendir / cb_open / cb_read / cb_statfs 六个回调体外套 catch (const std::bad_alloc&) → -ENOMEM、catch (...) → -EIO——terminate 并非窗口专属,任何逃逸出回调的异常都无 handler。cb_readdir/cb_releasedir/cb_release 不分配,不加。附带:cb_open 的 Handle 改 unique_ptr 接管,异常路径不再泄漏。

测试结果

  • 新增 tests/unit/test_lock_guards.cpp(Catch2 v3,已注册进 gitmount_tests):
    • ScopedUnlock:另一线程 try_lock 验证窗口内未锁、出作用域已重锁;窗口内 throw → 外层 lock_guard 正常展开后 mutex 处于可正常 lock/unlock 状态(无死锁/二次解锁);
    • SingleFlight:(a) 生成者抛异常 → 搭车者在 10s 截止内醒来、得到失败结果,随后 begin_or_wait 立即成为生成者(flag 已复位);(b) 成功路径生成者手动 finish,搭车者等待后返回并共享结果;(c) 裸 double-finish 无 UB,GeneratorFinish 手动 finish + 析构恰好触发一次(epoch 断言)。
    • 搭车者通过「持锁握手 + 截止期限轮询」避免回归时挂死测试套件(失败而非挂起)。
  • 本机(libgit2 1.9.7 / clang-format 18.1.8):
    • cmake -DCMAKE_BUILD_TYPE=Debug -DGITMOUNT_WERROR=ON 构建零告警;
    • ctest:unit + integration 全部通过(23 assertions / 5 test cases 新增);改动文件已过 clang-format -i(18.1.8,与 CI 一致)。
  • 集成验收中的 ulimit -v 放大 blob 场景需真实 /dev/fuse 环境按 issue 验收标准复核(本机 integration 套件通过;未以 root 运行)。

风险说明

  • 行为风险低:五个窗口与 open_commits 的锁获取/释放顺序逐行保持;/commits 可见语义(fast path / 搭车共享 / 失败重试)不变。变化仅在异常路径:UB/terminate/永久卡死 → 单次请求 -ENOMEM/-EIO 且系统继续服务。
  • list_commits 的分 chunk unlock/lock 回调对按 RFC 0000 §3.4 保留:其抛点(append/push_back)均在 mu_ 持有段内,unlock/lock 对之间无分配,外层 lock_guard 展开安全(已在实现中核实)。
  • CHANGELOG 的 [Unreleased] 段落与其他并行 PR 可能冲突,合并时取条目并集即可。
  • epoch() 访问器为测试/诊断而设(要求调用方持锁),生产代码未使用。
English summary

Five manual mu_.unlock()…mu_.lock() windows in the segmented-loading path had no exception safety: an allocation failure inside a window (attacker-sizable via ODB object headers, no cap) made the surrounding std::lock_guard unlock an already-unlocked mutex (UB) and the exception escape through libfuse's C frames into std::terminate. A throw inside /commits generation additionally wedged commits_generating_ forever: later opens blocked on the condition variable until libfuse's worker pool (default 10) was exhausted and the whole filesystem stalled.

Fix (issue #4, RFC 0000 §3.4 context): a ScopedUnlock RAII guard (src/lock_guards.hpp) replaces all five windows (re-lock on every exit path, unwinding included); the /commits single-flight state machine is extracted into a testable SingleFlight class with a GeneratorFinish guard that resets the flag, advances the wake-up epoch and notifies riders on every exit path — re-locking idempotently through the same unique_lock — with the generation body wrapped in catch (...) { ok = false; } (EIO, retry on next open per §3.5); and the six allocating FUSE callbacks (getattr/readlink/opendir/open/read/statfs) translate escaped exceptions at the boundary into -ENOMEM (bad_alloc) / -EIO. New Catch2 unit tests cover the guard semantics and both SingleFlight exit paths; full existing suites stay green.

Closes #4

安全审查发现(issue #4):分段加载的 5 处手写 mu_.unlock()…mu_.lock()
窗口毫无异常安全——窗口内的 std::string::assign / make_shared /
push_back 分配大小可被仓库内容驱动(ODB 解压无上限),失败时
(1) 外层 lock_guard 对未上锁的 mutex 解锁(C++/POSIX 双重 UB);
(2) 异常穿透 libfuse 纯 C 栈帧且全仓库无 handler → std::terminate,
挂载进程 SIGABRT,所有在途请求失败。
加重缺陷:/commits 生成段先置 commits_generating_=true 再解锁,
抛异常后 flag 永不复位、epoch 不再前进、无 notify——后续所有
/commits open 永久阻塞在条件变量上,每个阻塞 open 占用一个
libfuse worker(默认上限 10),耗尽后整个文件系统失去响应。

三部分同批修复:

- src/lock_guards.hpp 新增 ScopedUnlock RAII 守卫(构造解锁/析构
  重锁,含异常展开路径),替换全部 5 处手写窗口:tree_locked、
  commit_facts_locked、load_blob_locked、cb_open 超大 blob 钉住、
  cb_open commits 生成;窗口语义逐行不变(锁外读、锁内缓存
  重查/插入顺序保持)。
- /commits single-flight 状态机抽成可测的 SingleFlight +
  GeneratorFinish(finish 经同一 unique_lock 幂等重锁,任意退出
  路径复位 flag/推进 epoch/notify);open_commits 生成段包
  try/catch(bad_alloc 等映射为生成失败,§3.5 重试语义),可见
  语义不变:fast path、搭车者共享缓冲(0)或 -EIO、失败下次
  open 重试。
- 六个 FUSE 回调(getattr/readlink/opendir/open/read/statfs)
  边界兜底:catch (bad_alloc) → -ENOMEM,catch (...) → -EIO;
  cb_open 的 Handle 改 unique_ptr 管理以免异常路径泄漏。

新增 tests/unit/test_lock_guards.cpp(Catch2 v3):ScopedUnlock
窗口内未锁/出作用域重锁、异常展开后 mutex 可正常加解锁;
SingleFlight 生成者抛异常后搭车者限时醒来得到失败结果且 flag
已复位、成功路径搭车共享、finish 幂等且 GeneratorFinish 恰好
触发一次。CHANGELOG 记录 Unreleased/Fixed(RFC 0000 §3.4 语境)。

Closes #4
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.

异常安全:分段加载解锁窗口在分配失败时触发 mutex UB 与进程终止;/commits 生成异常使 single-flight 永久卡死

1 participant