Skip to content

fix(test): make the workspace-summary cache test deterministic on Windows - #221

Closed
Broccolito wants to merge 1 commit into
mainfrom
fix/workspace-summary-cache-test-flake
Closed

fix(test): make the workspace-summary cache test deterministic on Windows#221
Broccolito wants to merge 1 commit into
mainfrom
fix/workspace-summary-cache-test-flake

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

The flake

agents::workspace_summary::tests::test_cache_serves_and_invalidates_on_mtime
failed once on test (windows-latest) — run
34513035963,
3693 passed; 1 failed, panicking at workspace_summary.rs:457 with "new
top-level file surfaces after mtime change"
. A rerun of the same commit passed
with no code change; macOS and Ubuntu passed both times; the PR carrying it
(#219) does not touch this file. Both defects were in the test, and both are
visible by reading it.

Defect 1 — the promised nudge did not exist

The test wrote two.txt and immediately re-read, under a comment reading
"mtime resolution can be coarse; nudge the directory to be safe" — with no
nudge following it. The Windows clock ticks roughly every 15 ms and NTFS updates
a directory's own mtime lazily, so the two writes could leave dir_mtime
byte-identical. The cache then judged its entry fresh and served the stale map,
and neither summary named the new file.

Fixed by taking the signal rather than hoping for it: filetime::set_file_mtime
pins the directory's mtime to a fixed instant. std::fs cannot do this portably
— a directory needs FILE_FLAG_BACKUP_SEMANTICS to open for write on Windows,
which is exactly what filetime encapsulates. It was already in Cargo.lock
transitively, so this adds a dependency edge and no new package (the lockfile
diff is one line).

A pin_dir_mtime helper reads the timestamp back and asserts the filesystem
kept it, so a platform that quietly refuses the value fails naming the
timestamp
instead of downstream, where a stale map reads as a cache bug. That
is the general shape of the original bug: an unstated precondition failing
somewhere that blames the thing it precedes.

Defect 2 — the assertion could not fail

refreshed.contains("two.txt") || first.contains("two.txt")

first was captured before two.txt existed, so the second disjunct was dead.
It read as tolerating a race and tolerated nothing.

The test's name promises two behaviours, and it now checks both:

  • Half 1 — the cache serves. Add two.txt, restore the mtime to what the
    cached entry recorded, and assert the stale map comes back unchanged.
  • Half 2 — the cache invalidates. Move the mtime clearly forward and assert
    the new file surfaces.

What the dead disjunct gestured at is now a live assertion in the right place:
assert!(!first.contains("two.txt")), pinning the pre-state where it is
actually true.

Verified by mutation

Since the defect being fixed was an assertion that could not fail, both halves
were checked against a broken implementation:

Mutation Expected to fail Result
fresh drops the dir_mtime comparison (never invalidates) half 2 "a changed directory mtime rewalks and surfaces the new file"
fresh = false (never serves) half 1 "an unchanged directory mtime inside the TTL serves the cached map", with a diff showing two.txt present

Is the cache process-global? Yes — but #[serial] is not the fix

CACHE is a static Lazy<Mutex<HashMap<..>>>, and
test_collect_moim_uses_minute_granularity (extension_manager.rs:4278) does
reach it from a parallel thread through collect_moim. But it caches under
/tmp while this test caches under its own tempdir: keys never collide, so
no test can corrupt another's entry.

The entire coupling was the blunt whole-map clear_cache() — called only by
this test, and wiping the neighbour's entry on every run. So the blunt tool is
removed rather than serialized around: clear_cache() becomes
forget_cached(working_dir), with it and workspace_summary deriving the key
through a shared cache_key() so the two can never drift.

#[serial] would not have helped, for two independent reasons: with keys
already private there is nothing to serialize, and a lone #[serial] orders
only against tests that also carry the attribute — which the collect_moim
neighbour does not. Narrowing the reach is what actually holds.

Also

The two config keys the test rests on are asserted up front.
CONTEXT_WORKSPACE_SUMMARY and CONTEXT_WORKSPACE_SUMMARY_TTL_SECS resolve
from the environment or config.yaml, and the first is the documented lever for
a slow workspace walk — a developer with it off would otherwise have hit a
cryptic .expect("first summary") rather than a message about their own
configuration.

Deliberately not changed

The same coarse-mtime behaviour means production on Windows can serve a stale
map until the 30 s TTL lapses when a file lands in the same clock tick as the
previous walk. That is within the documented contract — the rendered header says
"may be truncated or slightly stale", and the TTL exists precisely as the
backstop for when mtime is an imperfect signal. It is a weaker signal on Windows
than on Unix, not a defect, so it is left alone.

Testing

  • cargo test -p biorouter --lib -- agents::workspace_summary11 passed, 0 failed
  • cargo clippy -p biorouter --lib --tests --all-features → clean
  • cargo fmt -p biorouter -- --check → clean
  • Both mutation runs above

All with BIOROUTER_DISABLE_KEYRING=true.

…dows

`agents::workspace_summary::tests::test_cache_serves_and_invalidates_on_mtime`
failed once on `test (windows-latest)` (run 34513035963, on a PR whose diff does
not touch this file) and passed on a rerun of the same commit. Two defects, both
in the test.

**The promised nudge did not exist.** The test wrote a second file and
immediately re-read, under a comment reading "mtime resolution can be coarse;
nudge the directory to be safe" — with no nudge following it. The Windows clock
ticks roughly every 15ms and NTFS updates a directory's own mtime lazily, so the
two writes could leave it byte-identical; the cache then served the stale map
and neither summary named the new file.

**The assertion could not fail.** `refreshed.contains("two.txt") ||
first.contains("two.txt")` captured `first` before `two.txt` existed, so the
second disjunct was dead. It read as tolerating a race and tolerated nothing.

Take the signal rather than hope for it: `filetime::set_file_mtime` pins the
directory's mtime (`std::fs` cannot — a directory needs
`FILE_FLAG_BACKUP_SEMANTICS` to open for write on Windows), and `pin_dir_mtime`
reads it back, so a filesystem that refuses the value fails naming the timestamp
instead of downstream where a stale map reads as a cache bug. The test's name
promises two behaviours and now checks both: half 1 restores the mtime and
asserts the cached map comes back unchanged, half 2 moves it forward and asserts
the new file surfaces. Verified by mutation — dropping the mtime comparison
fails half 2, and never serving from the cache fails half 1.

`clear_cache()` becomes `forget_cached(working_dir)`, with both it and
`workspace_summary` deriving the key through a shared `cache_key`. `CACHE` is
process-global and `cargo test` runs a crate's unit tests in parallel threads of
one process, where `test_collect_moim_uses_minute_granularity` reaches it via
`collect_moim`. Keys never collide, since each test owns a `tempdir`, so the
whole-map `clear()` was the only cross-test reach; narrowing it to one key
removes the coupling rather than serializing around it.

The two config keys the test rests on are now asserted up front:
`CONTEXT_WORKSPACE_SUMMARY` and `CONTEXT_WORKSPACE_SUMMARY_TTL_SECS` resolve
from the environment or `config.yaml`, and the first is the documented lever for
a slow workspace walk — a machine with it off would otherwise have failed with a
message about the file map rather than about its own configuration.
@Broccolito

Copy link
Copy Markdown
Collaborator Author

Superseded by #218. The flake this fixes was in test_cache_serves_and_invalidates_on_mtime, which no longer exists: #218 took the workspace-map walk off the turn's critical path and removed the cache's mtime early-invalidation along with it, because it cost a stat of the root on the async path. On main the file contains zero occurrences of mtime, CacheEntry (workspace_summary.rs:272) carries only computed_at: Instant, and the freshness decision (:421) is !ttl.is_zero() && entry.computed_at.elapsed() < ttl inside a section commented "Deliberately syscall-free", so Windows' 15 ms clock granularity cannot reach it on any platform.

The "cache serves" half is now covered by a_fast_walk_is_served_and_then_cached_for_the_ttl (:1015), which injects ttl/budget and panics if a cached map is re-walked inside the TTL; the "invalidates" half is TTL-lapse only, covered at :888. The clear_cacheforget_cached narrowing is superseded too: :646 records that there is deliberately no clear_cache because every test keys off its own root via unique_key (:688), so even the narrowed helper has no caller. Re-applying this PR's intent would require asserting invalidation on an mtime change, which would now fail. The filetime dev-dependency is not needed.

Closed without merging; the branch is deleted.

@Broccolito Broccolito closed this Sep 10, 2026
@Broccolito
Broccolito deleted the fix/workspace-summary-cache-test-flake branch September 10, 2026 20:16
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.

1 participant