Skip to content

Commit 078fcd6

Browse files
Merge pull request #6 from ZenosInteractive/reader/fix-stale-prefetch-reentry
fix(reader): respawn prefetch when cancelled chunk re-enters window b…
2 parents 348a220 + 19a6b33 commit 078fcd6

3 files changed

Lines changed: 131 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to the VTX SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased] - 2026-04-23
9+
10+
### Fixed
11+
- **reader**: `ReplayReader::UpdateCacheWindow` no longer leaves a chunk permanently stuck when it is cancelled by a window shift and then immediately re-requested before the worker thread has started running. The §1.A cancellation path (PR #4) flags out-of-window prefetches via `request_stop()` on their per-`PendingLoad` stop_source but leaves the map entry in place. If the chunk re-entered the window before its worker was scheduled, the `trigger()` lambda saw the stale entry, assumed a load was already in flight, and skipped spawning a new task. The original worker then ran, observed `stop_requested() == true` at its entry check in `PerformHeavyLoading`, returned an empty `CachedChunk`, and `AsyncLoadTask` skipped the cache write (correctly, because the stop was still requested). The future resolved cleanly, but the cache stayed empty. A synchronous caller waiting on that future in `GetFramePtrSync` then read the empty cache and returned `nullptr` -- manifesting as a spurious load failure under random-seek patterns. Fix: `trigger()` now detects a pending entry whose stop is already requested and replaces it with a fresh `PendingLoad`; the orphaned worker exits on its own and its `stop_requested()`-gated cache write still cannot pollute anything. Exposed by the TSan CI job on `ReaderApiFlatBuffers.RandomAccessSkipsLateralPrefetches`; the scheduling overhead of the ThreadSanitizer runtime makes the "cancelled before scheduled" race far more likely to manifest. Stock release builds had been papering over it by the workers happening to get past the entry check before cancellation landed
12+
13+
### Added
14+
- **tests**: `ReaderApiFlatBuffers.CancelledPrefetchReEntersWindow` -- focused regression for the bug above. Runs the cancel + re-enter pattern (prime chunks 0..2, jump to chunk 10 to cancel, jump to chunk 2 to revive) 50 times against a fresh reader each iteration. Pre-fix this fails ~every run under TSan and flakes at single-digit-% on stock release; post-fix it is deterministic green on both
15+
816
## [Unreleased] - 2026-04-22
917

1018
### Changed

sdk/include/vtx/reader/core/vtx_reader.h

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,29 @@ namespace VTX {
443443
}
444444

445445
void UpdateCacheWindow(int32_t current_idx) {
446+
// Orphaned PendingLoads moved out of pending_loads_ by trigger()
447+
// when it respawns a stale-cancelled entry. Declared BEFORE the
448+
// lock_guard so the vector -- and therefore each orphan's
449+
// std::shared_future destructor -- runs AFTER cache_mutex_ has
450+
// been released on function exit.
451+
//
452+
// libstdc++'s std::shared_future dtor blocks until the async
453+
// task completes when it holds the last reference to a state
454+
// created by std::async(std::launch::async, ...). The task
455+
// itself acquires cache_mutex_ in AsyncLoadTask to check
456+
// stop_requested() and (optionally) write chunk_cache_, so
457+
// destroying a to-be-cancelled shared_future WHILE holding
458+
// cache_mutex_ would deadlock: we would wait on a task that
459+
// cannot make progress without the mutex we hold.
460+
//
461+
// By moving the stale entry into orphans[] before overwriting
462+
// it in pending_loads_, we defer the shared_future dtor until
463+
// the lock_guard below has released the mutex. The task can
464+
// then observe its already-requested stop, bail at the entry
465+
// check in PerformHeavyLoading, skip the cache write, and
466+
// resolve its future -- unblocking the orphans[] destructor.
467+
std::vector<PendingLoad> orphans;
468+
446469
std::lock_guard<std::mutex> lock(cache_mutex_);
447470

448471
// §1.B -- access-pattern detection.
@@ -535,26 +558,62 @@ namespace VTX {
535558
const size_t max_concurrent_loads = 3;
536559

537560
auto trigger = [&](int32_t i) {
538-
if (!chunk_cache_.contains(i) && !pending_loads_.contains(i)) {
539-
if (pending_loads_.size() >= max_concurrent_loads) return;
540-
541-
if (evts.OnChunkLoadStarted) evts.OnChunkLoadStarted(i);
542-
543-
// Give each prefetch its own stop_source so we can
544-
// cancel it independently from the "window changed"
545-
// path above. The token is captured by value into the
546-
// lambda -- it's ref-counted, cheap to copy, and stays
547-
// valid even if the map entry is later erased while the
548-
// worker thread is still running (it won't be, because
549-
// we wait on the future first, but the invariant is
550-
// useful for reasoning about shutdown paths).
551-
PendingLoad pl;
552-
auto token = pl.stop.get_token();
553-
pl.future = std::async(std::launch::async, [this, i, token]() {
554-
this->AsyncLoadTask(i, token);
555-
}).share();
556-
pending_loads_[i] = std::move(pl);
561+
if (chunk_cache_.contains(i))
562+
return;
563+
564+
// A prefetch that was cancelled by a previous window shift
565+
// (line ~509) leaves its entry in pending_loads_ with the
566+
// stop_token already requested. If the chunk re-enters the
567+
// window before the worker thread has even started running
568+
// (easy under TSan's scheduling overhead, or any burst of
569+
// random seeks), the worker will observe stop_requested()
570+
// on entry and bail without populating chunk_cache_. The
571+
// future resolves cleanly, but GetFramePtrSync() then reads
572+
// an empty cache and returns nullptr -- a latent bug from
573+
// the §1.A cancellation work (PR #4).
574+
//
575+
// Detect that case here and replace the stale entry with a
576+
// fresh PendingLoad. The orphaned task will exit on its
577+
// own; its cache write is gated by a stop_requested() check
578+
// inside cache_mutex_ (see AsyncLoadTask) so it cannot
579+
// pollute the cache with empty data either.
580+
const bool stale = pending_loads_.contains(i) && pending_loads_[i].stop.get_token().stop_requested();
581+
582+
if (pending_loads_.contains(i) && !stale)
583+
return;
584+
585+
// The concurrency cap guards *new* slots. Respawning a
586+
// stale entry replaces an existing slot rather than adding
587+
// one, so don't gate the resurrection path on the cap --
588+
// otherwise we could refuse to revive a chunk the caller is
589+
// about to synchronously wait on.
590+
if (!stale && pending_loads_.size() >= max_concurrent_loads)
591+
return;
592+
593+
if (evts.OnChunkLoadStarted)
594+
evts.OnChunkLoadStarted(i);
595+
596+
// Relocate the stale entry to orphans[] before overwriting.
597+
// See the long comment at the top of UpdateCacheWindow for
598+
// why destroying its shared_future inside cache_mutex_
599+
// would deadlock under TSan.
600+
if (stale) {
601+
orphans.push_back(std::move(pending_loads_[i]));
557602
}
603+
604+
// Give each prefetch its own stop_source so we can
605+
// cancel it independently from the "window changed"
606+
// path above. The token is captured by value into the
607+
// lambda -- it's ref-counted, cheap to copy, and stays
608+
// valid even if the map entry is later erased while the
609+
// worker thread is still running (it won't be, because
610+
// we wait on the future first, but the invariant is
611+
// useful for reasoning about shutdown paths).
612+
PendingLoad pl;
613+
auto token = pl.stop.get_token();
614+
pl.future =
615+
std::async(std::launch::async, [this, i, token]() { this->AsyncLoadTask(i, token); }).share();
616+
pending_loads_[i] = std::move(pl); // overwrites moved-from entry if stale
558617
};
559618

560619
trigger(current_idx);

tests/reader/test_reader_api.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,51 @@ TEST(ReaderApiFlatBuffers, RandomAccessSkipsLateralPrefetches)
265265
<< jump_frames.size() << " jumps.";
266266
}
267267

268+
// Regression for the "stale-cancelled prefetch blocks re-entry" bug.
269+
//
270+
// Sequence that hits the race:
271+
// 1. GetFrameSync(frame_in_chunk_0) -- triggers chunk 0 (sync) plus
272+
// lateral prefetches of chunks 1 and 2. Workers for 1 and 2 are
273+
// queued on std::async but may not have started yet.
274+
// 2. GetFrameSync(frame_in_chunk_10) -- window shifts away from 0.
275+
// The UpdateCacheWindow cancel loop calls request_stop() on the
276+
// PendingLoads for chunks 1 and 2; their entries remain in
277+
// pending_loads_ until the workers exit and the next reap sweep
278+
// picks them up.
279+
// 3. GetFrameSync(frame_in_chunk_2) -- chunk 2 is back in the window.
280+
// Pre-fix: trigger() saw pending_loads_[2] and skipped spawning a
281+
// new task; worker 2 eventually ran, observed stop_requested(),
282+
// bailed at its entry check, and the chunk_cache_ write was
283+
// skipped. GetFramePtrSync waited on the future, it resolved,
284+
// cache was empty, returned nullptr.
285+
// 4. Post-fix: trigger() detects pending_loads_[2] has its stop
286+
// already requested and respawns with a fresh stop_source; the
287+
// orphaned worker exits on its own, the new worker populates the
288+
// cache, GetFramePtrSync returns the frame.
289+
//
290+
// The race is timing-dependent, so we run the pattern 50 iterations.
291+
// Under TSan's scheduler overhead a single iteration suffices; under
292+
// stock release it is a flaky single-digit-% race and 50 reps push
293+
// the miss probability below the CI flake floor.
294+
TEST(ReaderApiFlatBuffers, CancelledPrefetchReEntersWindow) {
295+
const auto path = VtxTest::OutputPath("ReaderApiFlatBuffers_CancelledPrefetchReEntersWindow.vtx");
296+
WriteReplay(VTX::VtxFormat::FlatBuffers, path, 100, 5); // 20 chunks * 5 frames
297+
298+
constexpr int kIters = 50;
299+
for (int iter = 0; iter < kIters; ++iter) {
300+
auto ctx = VTX::OpenReplayFile(path);
301+
ASSERT_TRUE(ctx) << "iter=" << iter << " " << ctx.error;
302+
ctx.reader->SetCacheWindow(2, 2);
303+
304+
// Step 1: prime chunks 0..2 (chunk 0 sync + 1,2 as laterals).
305+
ASSERT_NE(ctx.reader->GetFrameSync(0), nullptr) << "iter=" << iter << " step=1";
306+
// Step 2: jump far away -> cancels 1 and 2 before they run.
307+
ASSERT_NE(ctx.reader->GetFrameSync(50), nullptr) << "iter=" << iter << " step=2";
308+
// Step 3: jump back to a cancelled chunk. Pre-fix returns null.
309+
ASSERT_NE(ctx.reader->GetFrameSync(10), nullptr) << "iter=" << iter << " step=3";
310+
}
311+
}
312+
268313
// §3.A regression coverage. WarmAt must trigger an asynchronous load
269314
// of the chunk containing `frame_index` without blocking the caller,
270315
// and without requiring a subsequent GetFrame to fire the load.

0 commit comments

Comments
 (0)