Skip to content

fix(pine-cpp): make_window_view shares parent mu_ to close FlatMap race (#103 #109 #131) - #135

Merged
Liam0205 merged 6 commits into
masterfrom
fix/issue-103-window-view-mutex
Jun 21, 2026
Merged

fix(pine-cpp): make_window_view shares parent mu_ to close FlatMap race (#103 #109 #131)#135
Liam0205 merged 6 commits into
masterfrom
fix/issue-103-window-view-mutex

Conversation

@Liam0205

Copy link
Copy Markdown
Owner

Closes #103. Closes #109. Closes #131.

Root cause

#131 (2026-06-19) finally produced a TSan stack trace for the race that #109 / #124 had been failing to reproduce locally for two weeks. #103 (2026-06-14) had already predicted this exact hazard from architecture review:

  • RowFrame::make_window_view creates a new RowFrame instance whose view_items_ aliases parent's items_
  • Each RowFrame instance had its own std::shared_mutex mu_
  • So the same underlying storage was guarded by two distinct mutexes: parent's mu_ for parent operations, view's mu_ for shard operations
  • TSan stack on Nightly diff-fuzz: 0 failures, 1 unstable (2026-06-19) #131's unstable_008189: writer (T7) holds parent.mu_ in RowFrame::apply_output → FlatMap::operator[], reader (T21) holds view.mu_ in build_operator_input → with_read_lock → item_has_no_lock → FlatMap::find. Different mutexes, same FlatMap memory at 0x725400010020 → race.

The pre-#103 column_frame.hpp contract comment claimed "parent is read-only during the view's lifetime; parallel_execute satisfies this". That was true when written, but the v0.9.2 ready-queue scheduler refactor (llmdoc/memory/reflections/dag-ready-queue-scheduler.md) introduced no-field-conflict cross-op parallelism — which assumed field-level DAG edges sufficed to serialize storage access, but window views had been quietly aliasing storage under separate mutexes the whole time.

Fix: Option A from #109's evaluation

Per #109 evaluation comment and #103 implementation plan comment, Option A: parent and view share the same shared_mutex.

RowFrame and ColumnFrame private member changes:

- mutable std::shared_mutex mu_;
+ mutable std::shared_ptr<std::shared_mutex> mu_;

make_window_view (both Frame impls):

+ v->mu_ = mu_;   // shared_ptr copy: parent and view alias the same mutex

All lock acquire sites: lk(mu_)lk(*mu_) (30 sites total across the two Frame impls, mechanical).

4-commit layout

commit what independent?
da66bcde RowFrame mu_ → shared_ptr<shared_mutex>, both ctors make_shared, all locks *mu_. No behavior change at this commit ✓ revertable
0dea37ec Same migration for ColumnFrame. No behavior change at this commit ✓ revertable
9ed59b8f Core fix: v->mu_ = parent.mu_ in both make_window_view impls; refresh column_frame.hpp contract comment ✓ revertable (and reverting is what made the test verify the race)
bb6c981a New tests/test_window_view_race.cpp with deterministic Row/Column race reproducers; tight-loop writer (apply_output on parent) vs reader (item_has on view) for 500ms each ✓ test-only

Each commit independently builds and passes the cpp test suite. The split lets a future bisect localize regressions to either the type migration or the semantic change without ambiguity.

Empirical verification

The reproducer test was first run pre-fix (commit 3 reverted), produced multiple TSan WARNINGs in FlatMap::operator[] and vector::_M_realloc_insert (matching #131's stack exactly). Then commit 3 was un-reverted and the same test produced zero TSan reports across multiple Release + TSan invocations. The race is empirically gone.

Validation matrix:

check result
cpp test suite (Release) 213/213 PASS, 110k+ assertions
cpp test suite (TSan, post-fix) 213/213 PASS, 0 ThreadSanitizer reports
cpp test suite (TSan, pre-fix repro) reports race in FlatMap::operator[] within first iteration, matches #131 stack
scripts/cross-validate.sh (all 12 sections) all PASS
pine-go window-view audit No equivalent hazard — single Frame instance, single RWMutex; data_parallel splits OperatorInput projections, not Frame instances
pine-java window-view audit No equivalent hazard — same single-Frame model with ReentrantReadWriteLock

Cross-engine implementation note

The three engines take different shapes for data_parallel — and that's fine, parity is at the behavior layer, not the implementation layer:

engine data_parallel storage model
pine-go single Frame, multiple OperatorInput (each with offset/count); Frame's RWMutex serializes
pine-java single Frame, multiple OperatorInput; Frame's ReentrantReadWriteLock serializes
pine-cpp (this PR) multiple Frame instances (parent + N views), all sharing one shared_ptr<shared_mutex>; the shared_mutex serializes

C++ chose the multi-Frame view model because per-instance mutex is the natural C++ idiom; Go/Java single-Frame is the natural managed-runtime idiom. The hazard was specific to C++'s choice — once we share the mutex, the per-instance abstraction stays clean while storage-level locking is correct.

Performance trade-off

True-parallel cross-op pairs whose storage actually overlapped (predecessor's data_parallel shards still live + successor running concurrently with no field-level conflict) now serialize via shared_lock contention instead of running unsynchronized.

Magnitude prediction: < 3 % calibrated regression based on the small fraction of cross-op concurrency in calibrated's 38-op pipeline (most ops chain through fields, DAG edges already serialize). Per #129's plan, daily-sanitized-fuzz post-merge runs will validate.

If > 5 % regression appears: escalate to Option C (SharedMutex revival) per #109's evaluation. Not doing that speculatively.

Test plan

  • cpp test suite Release + TSan, both pre-fix (race confirmed) and post-fix (clean)
  • cross-validate 12 sections all PASS
  • pine-go / pine-java audit: no equivalent hazard
  • Post-merge: daily-sanitized-fuzz (and weekly nightly diff-fuzz) run for ~7 consecutive days with 0 unstable / 0 FAIL as the closure signal
  • Optional follow-up if calibrated regression > 5%: SharedMutex Option C (separate PR)

Out of scope

  • v0.9.2 scheduler's "field-conflict-only parallelism" heuristic. This PR fixes the storage layer; the scheduler still believes "no field conflict ⇒ safe to run together" but now the locking layer makes that belief actually true. Long-term we may want storage-aware scheduling (so e.g. data_parallel ops block successor apply_output deterministically, not via shared_lock contention), but that's its own design.

cc: #109, #131

Liam0205 added 4 commits June 21, 2026 22:07
…ed_mutex>

Pure type migration in preparation for #103 fix. Each RowFrame instance
still owns its own freshly-allocated shared_mutex via make_shared in
both ctors — behavior is unchanged at this commit. The next commit
migrates ColumnFrame the same way; the third commit then has
make_window_view copy parent's shared_ptr so view and parent alias the
same mutex (the actual race fix).

Why split it like this: doing the type migration and the semantic
change in one commit makes a footgun where reverting the fix also
reverts the type and breaks builds. With this split, each commit is
independently revertible.

Lock acquire sites: all `lk(mu_)` → `lk(*mu_)` (15 sites, mechanical).

Validation: cpp test suite passes (211/211 cases, 110k+ assertions).
No behavior change expected here.

Refs #103.
…hared_mutex>

Mirror commit for the column-storage Frame. Same shape as the previous
commit's RowFrame migration: type changes from `shared_mutex` to
`shared_ptr<shared_mutex>`, both ctors initialize via `make_shared`,
all 15 lock-acquire sites flip from `lk(mu_)` to `lk(*mu_)`. No
behavior change at this commit — each ColumnFrame still owns its own
mutex.

The next commit changes make_window_view (Row + Column together) so
the shared_ptr is copied from parent into view, restoring the
single-mutex-per-storage invariant that #103 / #109 / #131 require.

Validation: cpp test suite passes (211/211 cases, 110k+ assertions).

Refs #103.
…ce (#103 #109 #131)

Core fix. Both `RowFrame::make_window_view` and the static
`ColumnFrame::make_window_view(parent, ...)` now copy `parent.mu_`
(a shared_ptr<shared_mutex> after the previous two commits) into
`v->mu_`. Parent and view alias the same mutex; storage-level locking
finally spans both.

What this closes:

- Writer T7 in #131's TSan report holds parent's mu_ in
  `RowFrame::apply_output → FlatMap::operator[]` (vector emplace +
  string move into the freshly grown slot at 0x725400010020).
- Reader T21 in the same report holds view's mu_ (a different mutex
  pre-fix) in `build_operator_input → with_read_lock →
  item_has_no_lock → FlatMap::find → lower_bound` (string compare
  reading the same 0x725400010020).
- Two distinct mutexes guarding the same memory ⇒ TSan WARNING.
- Post-fix: the same shared_mutex serializes them. Concurrent shard
  reads still parallelize (shared_lock); apply_output across ops
  blocks on unique_lock until all live views release. The next
  commit's deterministic reproducer + #131 fixture lockdown verify
  this empirically.

Refresh of obsolete column_frame.hpp comment:

The pre-#103 doc claimed "parent is read-only during the view's
lifetime; parallel_execute satisfies this". That was true at the
time it was written, but the v0.9.2 ready-queue scheduler refactor
(`llmdoc/memory/reflections/dag-ready-queue-scheduler.md`) introduced
no-field-conflict cross-op parallelism — which assumes field-level
DAG edges are sufficient to serialize storage access, but window
views had been quietly aliasing storage under separate mutexes the
whole time. Comment replaced with the post-#103 reality: storage
layer enforces single-mutex serialization, scheduler can keep its
field-level parallelism.

Performance trade-off:

True-parallel cross-op pairs whose storage actually overlapped
(predecessor's data_parallel shards still live, successor running
concurrently with no field conflict) now serialize via shared_lock
contention instead of running unsynchronized. Calibrated benchmark
will quantify the impact in the PR body. If > 5 % regression, escalate
to Option C from #109's evaluation comment (SharedMutex revival).

Validation: cpp test suite passes (211/211 cases, 110k+ assertions).
The next commit adds the deterministic race reproducer that needs the
fix to pass.

Closes #103 (subject to PR-level merge).
Refs #109, #131.
…mutex (#103)

Adds tests/test_window_view_race.cpp with two doctest cases (RowFrame
and ColumnFrame). Each spawns a writer-thread tight-looping
parent.apply_output and a reader-thread tight-looping
view.item_has(...). The threads run for 500ms; the doctest assertions
only verify both made progress.

The actual diagnostic comes from running the binary under
-fsanitize=thread (the cpp-tsan CI job already does this for the whole
test suite). Empirical verification:

- Pre-fix (commit 3 reverted): TSan reports `data race in
  pine::FlatMap::operator[]` and the underlying `vector::_M_realloc_insert`
  within the first iteration. Two distinct mutexes (parent.mu_, view.mu_)
  guard the same FlatMap memory — exactly the race #131 captured in
  production.
- Post-fix (commit 3 applied): TSan reports zero races across multiple
  runs. The shared_ptr<shared_mutex> aliasing causes shared_lock and
  unique_lock to actually exclude each other.

Why not also add #131's unstable_008189 case as a fixture: the case
reproduces non-determinism, not a deterministic divergence. Once #103
is fixed, the fuzz round produces stable output, so there's nothing
for a fixtures/errors-style negative test to lock. The synthetic race
reproducer is the better regression signal — it directly exercises the
storage-level lock contract.

Validation: cpp test suite passes (211→213 cases, all 4 new test-case
expansions clean under both Release and TSan builds).

Refs #103, #109, #131.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 PR 审查

项目 结果
结论 ✅ APPROVE
审查截止 bb6c981aad49f1f076b66ceaa51ec60aa110962b

核心修复正确:mu_ 改为 shared_ptr<shared_mutex> 并在 make_window_view 中由 parent 拷贝给 view,使别名同一存储的 parent/view 共用同一把锁,关闭了 #131 的 FlatMap 跨锁竞争。三步提交拆分(先类型迁移再语义变更)合理,15 处锁点 lk(mu_)lk(*mu_) 迁移完整一致,回归测试方向正确。仅 1 条非阻塞建议。

🟠 重要建议 (1)

  • 文件: pine-cpp/include/pine/row_frame.hpp 代码链接
  • 问题: mu_ 从内联 std::shared_mutex 改为 std::shared_ptr<std::shared_mutex> 后,RowFrame 由"隐式不可拷贝/不可移动"(shared_mutex 既不可拷贝也不可移动)变为隐式可拷贝且可移动——其余成员(common_/items_ vector/warnings_/裸指针/size_t)均可拷贝。一旦发生值拷贝,两个"独立" Frame 会共享同一把锁(拷贝 shared_ptr)却各自深拷贝 items_ 存储,语义错配。ColumnFrame 因持有 unique_ptr<ColumnStore> items_ 仍不可拷贝,二者出现不对称。
  • 现状: 全仓库当前无 RowFrame 值拷贝点,属潜在隐患而非现行 bug,不阻塞合并
  • 建议: 显式 RowFrame(const RowFrame&) = delete; 及移动版本(或基类 Frame 统一删除拷贝/移动),把此前由编译器免费保证的不可拷贝不变量重新钉住,避免后续误用。
已核查(确认无问题)
  • 锁点迁移完整性: row_frame.cpp / column_frame.cpp 各 15 处锁点均已改为 *mu_,无遗漏的 lk(mu_)resource.cpplk(mu_) 属无关的独立类,不在本 PR 范围。
  • 虚函数路径共享锁: ColumnFrame::make_window_view(offset,count) 虚override 委托静态工厂,静态工厂内 v->mu_ = parent.mu_RowFrame 虚override 直接 v->mu_ = mu_。两条实际被 engine.cpp parallel_execute 调用的路径都正确共享了锁。
  • make_window_view 内未加锁读 parent.items_: 仅读取行数/指针等元数据(apply_output 不修改行数与容器地址,只改 FlatMap 内容),非竞争面,且为改动前既有模式,不引入新竞争。
  • *mu_ 解引用安全: 两个构造函数均 make_shared 初始化,make_window_view 拷贝非空 parent.mu_,mu_ 不会为空。

…135 review)

Address bot-reviewer concern on the previous PR commits: switching mu_
from `std::shared_mutex` to `shared_ptr<std::shared_mutex>` removed
the implicit non-copyable/non-movable guarantee that the inline
shared_mutex member used to provide. Every other RowFrame member is
copyable, so post-migration RowFrame became implicitly
copy/move-constructible. A value copy would have aliased the mutex
(shared_ptr copy) while deep-copying items_ — two "independent"
frames sharing one lock for unrelated data, exactly the kind of
subtle hazard #103 was about preventing.

ColumnFrame stayed non-copyable through its `unique_ptr<ColumnStore>`,
producing the asymmetry the reviewer flagged.

Fix: explicitly delete copy/move ctors and assignments on both
RowFrame and ColumnFrame. RowFrame restores the lost invariant;
ColumnFrame pins the implicit one explicitly so the contract is
self-describing rather than depending on a future reader noticing
the unique_ptr member.

No call site copies or moves either Frame in the current tree; this
commit is purely about preventing future misuse. Test suite still
passes (213/213, 110k+ assertions) — no value-copy paths existed to
break.

Refs #103, #135.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 PR 增量审查

项目 结果
结论 ✅ APPROVE
审查范围 bb6c981..45fcabe
审查截止 45fcabea526f47dfa6e719bfdbbf5a33203f9d40

增量仅新增了 RowFrame / ColumnFrame 的 copy/move 构造与赋值的显式 = delete,干净地修复了上轮审查指出的隐患:mu_std::shared_mutex 迁移到 shared_ptr<shared_mutex> 后,RowFrame 丢失了隐式不可拷贝性,值拷贝会出现「别名同一把锁但深拷贝 items_」的危险。

验证结论:删除拷贝/移动不会破坏现有代码。

  • 两个 Frame 始终经 new / make_unique 构造并由 unique_ptr 持有,全树无任何 Frame 对象的值拷贝/移动路径。
  • make_window_view 内部均为 new RowFrame() / new ColumnFrame() 后逐字段赋值,返回时 move 的是 unique_ptrrow_frame.cpp#L312-L326column_frame.cpp#L136-L147),与 pointee 的 move 语义无关。
  • 默认构造函数仍显式声明保留,new RowFrame() 不受影响。
  • RowFrame / ColumnFrame 两侧对称处理,注释清晰说明了「显式钉死隐式契约」的动机,符合自描述原则。

代码良好,无阻塞或重要问题,已检查 bug 与代码规范。

Carries the #103 / #109 / #131 storage-mutex fix:

  da66bcd refactor(pine-cpp): RowFrame mu_ shared_ptr<shared_mutex>
  0dea37e refactor(pine-cpp): ColumnFrame mu_ shared_ptr<shared_mutex>
  9ed59b8 fix(pine-cpp): make_window_view shares parent mu_ (#103 #109 #131)
  bb6c981 test(pine-cpp): deterministic race reproducer for window-view
  45fcabe fix(pine-cpp): explicitly delete Row/Column Frame copy & move

This is a pine-cpp-only correctness fix — no API surface change, no
JSON contract change, no Go/Java behavioral impact. Per-engine audit
on #135 confirmed pine-go and pine-java do not have an equivalent
hazard (their data_parallel uses single-Frame OperatorInput
projection; only pine-cpp's multi-Frame view model needs the
shared-mutex aliasing).

bump-version.sh ran the full four-runtime test sweep + 12
cross-validate sections inline before this commit. All PASS.

Next: merge #135 → master, then `make tag-release` to publish v0.10.9
(release.yml workflow_call gate per #126 will run full CI on the tag
SHA before pypi-apple / java jobs publish).
@github-actions

Copy link
Copy Markdown
Contributor

🔍 PR 增量审查

项目 结果
结论 ✅ APPROVE
审查范围 45fcabe..2607212
审查截止 26072120ac90bfccc89e2e22a52d0795ecb9761f

增量仅为版本号 bump 0.10.8 → 0.10.9,共 67 个文件、每文件 1 行,无任何逻辑改动。核心修复(前序提交的 make_window_view 共享 mu_、显式删除 Frame 拷贝/移动)已在前两轮审查 APPROVE,本轮不重复。

验证结论:版本号跨引擎同步一致,符合 conventions.md 的版本同步要求。

  • 单一真源 pine-go/version.go 0.10.9
  • pine-cpp kVersion 0.10.9,与注释要求的「Keep in sync with pine-go」一致
  • pine-java pom.xml 0.10.9、apple _version.py 0.10.9
  • 所有 fixtures / testdata 的 _PINEAPPLE_VERSION 与 codegen 产物同步刷新至 0.10.9,无遗漏旧版本串

代码良好,无阻塞或重要问题。整个 PR(#103 #109 #131 修复 + 版本发布)可合并。

@Liam0205
Liam0205 merged commit 24975c2 into master Jun 21, 2026
21 checks passed
Liam0205 added a commit that referenced this pull request Jun 21, 2026
…135 review)

Address bot-reviewer concern on the previous PR commits: switching mu_
from `std::shared_mutex` to `shared_ptr<std::shared_mutex>` removed
the implicit non-copyable/non-movable guarantee that the inline
shared_mutex member used to provide. Every other RowFrame member is
copyable, so post-migration RowFrame became implicitly
copy/move-constructible. A value copy would have aliased the mutex
(shared_ptr copy) while deep-copying items_ — two "independent"
frames sharing one lock for unrelated data, exactly the kind of
subtle hazard #103 was about preventing.

ColumnFrame stayed non-copyable through its `unique_ptr<ColumnStore>`,
producing the asymmetry the reviewer flagged.

Fix: explicitly delete copy/move ctors and assignments on both
RowFrame and ColumnFrame. RowFrame restores the lost invariant;
ColumnFrame pins the implicit one explicitly so the contract is
self-describing rather than depending on a future reader noticing
the unique_ptr member.

No call site copies or moves either Frame in the current tree; this
commit is purely about preventing future misuse. Test suite still
passes (213/213, 110k+ assertions) — no value-copy paths existed to
break.

Refs #103, #135.
@Liam0205
Liam0205 deleted the fix/issue-103-window-view-mutex branch June 21, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant