A five-line statement reorder inside a #if DEBUG test seam, one new regression test, and one long-broken assertion repaired. Small diff, outsized blast radius: the defect silently cleared search match state under any test that did more than 200 ms of async work, and it is currently blocking PR #322.
setActiveSearchQueryForTesting cancelled the debounce task before assigning searchQuery — but that assignment's didSet calls scheduleSearchUpdate(), which starts a fresh 200 ms task. The cancel only ever killed a task from an earlier call, so the helper reliably leaked exactly one task it never cancelled.activeSearchQuery, whose didSet guard short-circuits the recompute), so only the match cursor was cleared — and tests read that cursor via #require as a precondition. Failures therefore pointed at whatever subsystem was under test rather than at the harness.prismTests/WebRendering/WebSearchWiringTests.swift:113-163 calls the helper, calls navigateToMatch(at: 0), then polls a live WebView for seconds. Pre-fix the leaked task nil'd the cursor mid-poll, so "the current match has no distinct highlight" could only pass by luck.navigateToMatch, then multi-second await waitUntil { … } polling on session.currentMatch. This fix repairs them.#expect(currentGlobalMatchIndex == 0) could never hold, so the test ran on with a nil cursor and its closing == nil assertion was vacuous (nil → nil). Inserting navigateToMatch(at: 0) gives that closing assertion teeth for the first time.DocumentSessionParseGenerationTests (a different subset failed on each of two runs; all pass in isolation; none touch search) plus the known ancestorMapTracksNesting (T-1961, out of scope).Ready to push
The fix is correct, minimal, and deterministic. Four independent review passes (reuse, quality, test correctness, docs/process) found no blockers and no majors. The reorder is safe by construction rather than by luck: the Task {} created inside scheduleSearchUpdate() inherits MainActor isolation, so it cannot begin before the helper returns, and Swift task cancellation is sticky — a task cancelled before its first suspension throws out of Task.sleep and is caught by the existing guard !Task.isCancelled.
All four state paths (differing query, sub-minimum query, empty query, same query) were traced statement-by-statement in both orderings. The only behavioural difference is the intended one: the leaked task's late currentGlobalMatchIndex = nil no longer fires. Everything else — hasAnnouncedResultsForQuery, matchCountsPerBlock, activeSearchQuery, announcement ordering — is byte-for-byte identical.
SearchCoordinatorTests is 49/49 green, SwiftLint reports 0 violations across 497 files, and both platform builds succeed. Remaining findings are minor/nit follow-ups that do not need to gate the push; three purely editorial ones were applied during this review and need committing.
eb583e2 Fix T-1960: setActiveSearchQueryForTesting left an uncancelled debounce working-tree Editorial fixes applied during this review (uncommitted) Five lines moved around inside a helper that only exists when the app is built for testing, plus one new test and a one-line fix to an old test.
The app's search box does not search on every keystroke — that would be wasteful. Instead it waits 200 milliseconds after you stop typing, then searches. That pause is called a debounce.
Tests do not want to wait 200 ms, so there is a shortcut helper that jams a search query straight in. The helper tried to be tidy by cancelling any pending 200 ms timer first, and only then setting the query. But setting the query is exactly what starts a new timer. So the helper cancelled an old timer and then immediately lit a new one it never cleaned up.
That stray timer went off about 200 ms later and wiped out which search result was currently selected. Any test that called the helper and then did anything slow — waiting for a web view to load, say — would find its selected match mysteriously gone.
The cruel part: the number of matches survived, only the selection vanished. So a broken test looked like the search feature was broken, not the test helper. Engineers went looking in the wrong place.
didSet) — Swift lets you attach code that runs automatically every time a value is assigned. Handy, but it means an innocent-looking assignment can start real work.SearchCoordinator separates the raw input (searchQuery, bound to the UI) from the applied query (activeSearchQuery, which drives highlighting). A didSet on searchQuery calls scheduleSearchUpdate(), which cancels the previous searchDebounceTask, then stores a new Task that sleeps 200 ms and applies the query.
The DEBUG-only setActiveSearchQueryForTesting(_:) bypasses the wait so tests stay synchronous. Its old body was:
searchDebounceTask?.cancel() // kills a task from an EARLIER call
searchDebounceTask = nil
searchQuery = query // didSet -> schedules a NEW task
activeSearchQuery = query
currentGlobalMatchIndex = nilThe cancel guarded the wrong task. Every call left one live task behind.
This is the classic hazard of coupling side effects to property observers: a caller cannot assign the property without also triggering the work, so any cleanup must be sequenced after the assignment, not before. The fix therefore reads as an ordering constraint, and the diff documents it in place so the next reader does not "tidy" the cancel back to the top.
The cancel is deliberately kept unconditional. In the same-query case the assignment is a no-op (Swift's didSet fires, but the guard if searchQuery != oldValue suppresses scheduling), so no new task exists — yet a task from an earlier direct searchQuery write may still be pending, and only an unconditional cancel kills it.
The chosen shape is ordering-dependent, which is inherently a little fragile. Three alternatives were weighed and rejected: a shared private applyQuery (doesn't fix anything — the bug is the searchQuery write itself, which the helper must perform because production UI reads that property); a private-storage split behind a computed searchQuery (needs manual @Observable withMutation/access plumbing on a hot production property to serve a DEBUG helper); and a generation counter checked in the task body (would make ordering irrelevant, but adds production state to protect two lines of a test seam).
What makes the ordering hack tolerable is that the new regression test pins both implicit dependencies — MainActor inheritance and the guard !Task.isCancelled check. Delete either and the test goes red.
Two properties make this safe rather than lucky.
1. No suspension point between creation and cancellation. scheduleSearchUpdate() is a synchronous @MainActor method. The unstructured Task {} it creates inherits MainActor isolation, so its body is enqueued on the main actor and cannot begin executing while the current synchronous run — which continues into the helper's .cancel() two lines later — still holds the actor. The window in which the task could observe uncancelled state does not exist.
2. Cancellation is sticky and checked twice. Even were the task to start, it suspends immediately on try? await Task.sleep(for: .milliseconds(200)), which throws CancellationError on an already-cancelled task; the try? swallows it and the subsequent guard !Task.isCancelled else { return } catches the residual path. Neither activeSearchQuery nor currentGlobalMatchIndex is reachable post-cancel.
All four paths were traced in both orderings, with SearchService.minimumQueryLength == 2:
hasAnnouncedResultsForQuery, writes nothing) → activeSearchQuery = query → recomputeMatchCounts (writes matchCountsPerBlock, clamps, may announce) → cursor nil. Identical; old order additionally leaves a live task.scheduleSearchUpdate early-returns via the guard newQuery.count >= minimumQueryLength path, setting activeSearchQuery = "", matchCountsPerBlock = [], cursor nil; the helper then writes activeSearchQuery = query, whose didSet recompute clears again. Identical in both orders.activeSearchQuery = "" is suppressed by the inequality guard. Identical.didSet body guarded, no schedule, no flag reset, no recompute; only the cursor is nil'd. Identical, plus the leftover task is now killed.Crucially, hasAnnouncedResultsForQuery = false is set inside scheduleSearchUpdate in both orderings, and in both it lands before the activeSearchQuery write that triggers recomputeMatchCounts — so the VoiceOver announce decision is unchanged. The single observable delta is the elimination of the late currentGlobalMatchIndex = nil.
searchDebounceTask = nil is now meaningful where before it was theatre: in the old position it was immediately overwritten with a live task by scheduleSearchUpdate, so the field's value carried no information. In the new position it establishes the invariant the docstring advertises and mirrors clearSearch().
Production paths do not share the trap: clearSearch() assigns searchQuery = "", which takes the same below-minimum early return and schedules nothing. This is genuinely a harness-only defect with no missing production counterpart.
The one residual soft spot is the new test's 400 ms sleep against a hard-coded 200 literal. Post-fix nothing mutates state, so the test cannot flake red — the only load-induced failure mode is the opposite, the pre-fix task not having fired inside 400 ms, i.e. the regression test silently losing its teeth. The same risk applies if the debounce interval is ever raised above 400 ms. Hoisting 200 into a static let and sleeping 2 × it would close that, and is the recommended follow-up rather than a gate.
SearchCoordinator.swift
Why it matters. This is the entire behavioural fix. In the old order the cancel guarded a task from a previous call while the assignment's didSet lit a fresh 200ms task that nothing ever cancelled. That task later nil'd currentGlobalMatchIndex, corrupting search state for any test doing >200ms of async work — and because match counts survived, the failure masqueraded as a broken feature rather than a broken harness.
What to look at. prism/Services/SearchCoordinator.swift:459-465 (setActiveSearchQueryForTesting)
SearchCoordinatorTests.swift
Why it matters. Verified as a genuine regression test, not a tautology: on the pre-fix helper the leaked task fires during the 400ms sleep and nils the cursor, failing the final assertion. It asserts only public state (activeSearchQuery, totalMatchCount, currentGlobalMatchIndex) and never touches the private task handle, so it would survive replacing the Task-based debounce with a Combine debounce or an injected clock.
What to look at. prismTests/SearchCoordinatorTests.swift:67-96
SearchCoordinatorTests.swift
Why it matters. This one-line insert repairs a test that was born red. git log -S confirms it was added by 782312d (T-450, 2026-05-23) asserting currentGlobalMatchIndex == 0 immediately after the helper — but the helper's final statement has always set the cursor to nil, so the assertion never held.
What to look at. prismTests/SearchCoordinatorTests.swift:833 (navigateToMatch(at: 0))
SearchCoordinator.swift
Why it matters. The added docstring overclaimed parity. For a below-minimum-length query production sets activeSearchQuery to "" while the helper sets it to the query itself — an observable difference, since currentMatchIndexInBlock guards on !activeSearchQuery.isEmpty and SearchStateFeeder reads the query directly. And 'recomputeMatchCounts() runs via didSet' only holds when the value actually changes; repeating the same query does nothing but nil the cursor.
What to look at. prism/Services/SearchCoordinator.swift:447-455
The ordering dependency is real but narrow, and made safe by MainActor task inheritance plus sticky cancellation. Restructuring searchQuery into a private-storage split would add manual @Observable plumbing to a hot production property purely to serve a #if DEBUG helper — inverting the cost/benefit. A generation counter in the task body would make ordering irrelevant for all future callers at roughly four lines of new production state; defensible, but the mutation surface being protected is two lines.
Stated in the commit message and load-bearing. In the same-query case the assignment is a no-op, so didSet never fires and this call schedules nothing — but a task from an earlier direct searchQuery write may still be pending, and only an unconditional cancel kills it. This half of the fix is currently untested (see follow-ups): a future simplification that wrapped the cancel in a conditional would regress silently.
Not strictly required — the handle would be replaced on the next schedule — but in the new position it establishes the invariant the docstring advertises ("no debounce task running once it returns") and mirrors clearSearch(). In the old position it was overwritten with a live task two statements later, so the field's value carried no information at all.
The test's name and its final assertions describe real, valuable behaviour (Req 5.4: toggling showHTMLComments off resets the cursor to nil when no matches remain). Only its middle precondition was wrong. Repairing the precondition preserves the coverage; deleting the test would have discarded the only assertion of that requirement.
Author's rationale: the helper is #if DEBUG and unreachable from a shipped build. Accepted for this review, with one contrary data point recorded for the author: the [Unreleased] section already carries a test-only entry (T-1577), and two prior harness-only fixes (T-1440/T-1441, T-1457) were logged there and later pruned by prism-release-prep, which explicitly removes test additions at release time. Under that model [Unreleased] is the accumulator and release-prep is the filter — so "it never ships" is already guaranteed by the pruning step rather than by omission. Non-blocking either way.
In line with current practice: only 3 of the last 19 bugfix commits on main added a report, and the four most recent (T-1655, T-1829, T-1876, T-1851) all skipped it. The substantive content a report would carry — root cause, the seam audit conclusion, the T-450 archaeology — is already in the commit message and the in-code comments.
The #if DEBUG block now carries the trap in a comment and the regression test locks it in, so a future session cannot walk into it without reading both. The generalizable lesson — a test seam writing a property whose didSet starts async work — suits /capture-knowledge better than this repo. The stale existing note was the real risk and has been corrected (see Findings).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | SearchCoordinator.swift:447-455 docstring | Docstring claimed match state after the helper "matches the production debounce path" without qualification. False for a below-minimum-length query (production clears activeSearchQuery; the helper sets it to the query) and for a repeated same query (no recompute runs at all). | Applied: scoped the claim to queries of at least SearchService.minimumQueryLength that differ from the active one, and documented both exceptions. Comment-only. |
| minor | specs/document-session-extraction/implementation.md:82 | Spec implementation record stated the helper cancels any in-flight task "first" — now factually inverted by this change, and exactly the sort of stale sentence that would invite someone to move the cancel back. | Applied: rewritten to say the cancel runs after the assignment, and why (the assignment itself schedules a task), citing T-1960. |
| nit | prismTests/SearchCoordinatorTests.swift:831-832 comment | "had been failing on main ever since (T-1960)" parses as though T-1960 caused the failure; T-1960 is the fix. The test was added already-red by 782312d (T-450). The commit message gets this right; only the in-code comment was misleading. | Applied: now reads "which it never did, so it had been failing on main ever since it was added in T-450 (fixed here under T-1960)". Comment-only. |
| minor | ~/.claude/.../memory/project_preexisting_testquick_failures.md | The project memory note listed recomputeResetsCursorToNilWhenNoMatches as a DETERMINISTIC pre-existing main failure. Left stale it is actively harmful — it is precisely the note a future session would use to dismiss a real failure here as pre-existing. | Applied: marked FIXED 2026-07-26 (T-1960) in the note's existing style, with a one-line summary of the second harness defect the ticket fixed. |
| minor | Debounce interval is a bare literal | The 200ms debounce is an inline literal at SearchCoordinator.swift:424, and four test sleeps (250ms x3, now 400ms) are hand-picked against it. If the interval is ever raised above 400ms the new regression test passes vacuously — it can never flake red, only lose its teeth. Contrast SearchService.minimumQueryLength, which IS a shared constant. | Not fixed — requires a production change (hoisting the literal into a static let and deriving the sleeps). Reported for the author. Recommended follow-up. |
| minor | Untested half of the fix | Nothing covers the same-query path the unconditional cancel exists for: a direct searchQuery write schedules task A, then setActiveSearchQueryForTesting with the same value is a no-op assignment, and only the unconditional cancel kills A. A future refactor conditionalising the cancel would regress silently. | Not fixed — adding a test case is a test-logic change and out of scope for this review. Reported as a recommended follow-up. |
| nit | prismTests/SearchCoordinatorTests.swift:82-85 | navigateToMatch(at: 1) is called before try #require(expectedTotal > 1). Reads oddly for a precondition. Confirmed harmless: navigateToMatch is a silent no-op for an out-of-bounds index, the count is unaffected by navigation, and the #require still fails first with its explanatory message. | Not fixed — reordering statements is a test-logic change. Cosmetic only. |
| nit | Comment volume | The same narrative appears three times on this branch: an 8-line docstring addition, a 7-line inline comment, and a 5-line test comment, on top of the commit message — roughly 20 lines of prose for a 5-line helper. CLAUDE.md asks for concise wording. | Not trimmed — every claim is now accurate after the docstring correction, and this is a defect whose whole nature is 'looks tidy, is wrong', so in-place warning has unusual value. Flagged as author's call. |
| nit | Fixture duplication in SearchCoordinatorTests | The "Hello world test content" / "Another test paragraph" fixture literal now appears three times (:53-56, :75-78 new, :364-367). Two of the three pre-date this diff. | Not fixed — test refactor, out of scope. Hoisting to a static let or a makeCoordinator default argument is the tidy-up if anyone touches this file again. |
| nit | iOS build warnings (pre-existing) | make build-ios emits 22 warnings, all the same: "main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context; this is an error in the Swift 6 language mode". None in the files this branch touches; the image subsystem is untouched here. | Not fixed — pre-existing and unrelated. Worth its own ticket, since it becomes a hard error under the Swift 6 language mode. |
Click to expand.
diff --git a/prism/Services/SearchCoordinator.swift b/prism/Services/SearchCoordinator.swiftindex a01b27a..aee354d 100644--- a/prism/Services/SearchCoordinator.swift+++ b/prism/Services/SearchCoordinator.swift@@ -439,10 +439,32 @@ final class SearchCoordinator: SearchActions { /// /// This is for testing only. In production, use `searchQuery` which /// properly debounces updates.+ ///+ /// The helper installs its state synchronously and leaves **no** debounce+ /// task running once it returns, so a caller can do arbitrarily long+ /// asynchronous work afterwards without the search state changing under it.+ ///+ /// For a query of at least `SearchService.minimumQueryLength` characters+ /// that differs from the active one, match state after this call matches the+ /// production debounce path: the query is applied, `recomputeMatchCounts()`+ /// runs via `activeSearchQuery`'s `didSet`, and the current-match cursor is+ /// reset. Tests that need a selected match call `navigateToMatch(at:)`+ /// afterwards. (Repeating the same query only resets the cursor; a+ /// shorter-than-minimum query leaves `activeSearchQuery` set to it rather+ /// than clearing it as production would, so the helper is not meant for+ /// exercising the below-minimum path.) func setActiveSearchQueryForTesting(_ query: String) {+ // Order matters. Assigning `searchQuery` fires its `didSet`, which calls+ // `scheduleSearchUpdate()` and starts a *fresh* 200ms debounce task.+ // Cancelling before the assignment only kills a task left over from an+ // earlier call; the one this call schedules would survive, fire ~200ms+ // later, and nil `currentGlobalMatchIndex` out from under the test that+ // called us — a harness failure that surfaces as the feature under test+ // looking broken (T-1960). Cancel *after* the assignment instead.+ searchQuery = query searchDebounceTask?.cancel() searchDebounceTask = nil- searchQuery = query+ activeSearchQuery = query currentGlobalMatchIndex = nil }
diff --git a/prismTests/SearchCoordinatorTests.swift b/prismTests/SearchCoordinatorTests.swiftindex e509ae5..119efa2 100644--- a/prismTests/SearchCoordinatorTests.swift+++ b/prismTests/SearchCoordinatorTests.swift@@ -64,6 +64,37 @@ struct SearchCoordinatorTests { #expect(coordinator.totalMatchCount > 0) } + // T-1960: the helper assigns `searchQuery`, whose `didSet` schedules a fresh+ // 200ms debounce Task. The cancel at the top of the helper only killed the+ // *previous* task, so the one it scheduled itself survived the call, fired+ // after the helper returned, and nil'd `currentGlobalMatchIndex` — clearing+ // the state out from under any test that did >200ms of work before asserting.+ @Test("setActiveSearchQueryForTesting leaves no debounce task that later clears match state")+ @MainActor+ func setActiveSearchQueryLeavesNoPendingDebounce() async throws {+ let blocks: [MarkdownBlock] = [+ .paragraph(markdown: "Hello world test content"),+ .paragraph(markdown: "Another test paragraph")+ ]+ let coordinator = makeCoordinator(blocks: blocks)++ coordinator.setActiveSearchQueryForTesting("test")+ coordinator.navigateToMatch(at: 1)+ let expectedTotal = coordinator.totalMatchCount+ try #require(expectedTotal > 1, "fixture must offer more than one match")+ try #require(coordinator.currentGlobalMatchIndex == 1)++ // Well past the 200ms debounce window.+ try await Task.sleep(for: .milliseconds(400))++ #expect(coordinator.activeSearchQuery == "test")+ #expect(coordinator.totalMatchCount == expectedTotal)+ #expect(+ coordinator.currentGlobalMatchIndex == 1,+ "the helper must not leave a timer running that clears the selected match"+ )+ }+ // MARK: - clearSearch @Test("clearSearch resets all search state")@@ -795,6 +826,12 @@ struct SearchCoordinatorTests { // ON: comment text matches. coordinator.setActiveSearchQueryForTesting("needle") #expect(coordinator.totalMatchCount == 1)+ // Applying a query resets the cursor (the production debounce path does+ // the same), so select the match explicitly before asserting it is+ // dropped below. This test previously asserted the helper left the+ // cursor at 0, which it never did, so it had been failing on main ever+ // since it was added in T-450 (fixed here under T-1960).+ coordinator.navigateToMatch(at: 0) #expect(coordinator.currentGlobalMatchIndex == 0) // OFF: the only match disappears; the cursor goes to nil (0-of-0
diff --git a/specs/document-session-extraction/implementation.md b/specs/document-session-extraction/implementation.mdindex ea2c122..7b7c8f0 100644--- a/specs/document-session-extraction/implementation.md+++ b/specs/document-session-extraction/implementation.md@@ -79,7 +79,7 @@ Both use `[weak self]` captures to prevent retain cycles. This follows Swift's t **`toggleSection` guard-first pattern**: During review, the implementation was updated to check `findSection(id:)` before modifying `collapsedSectionIds`. The original extraction unconditionally modified the Set then returned the section lookup. This meant non-existent IDs would silently enter the collapsed set — benign but state-polluting. The fix verifies existence first, returning nil early for unknown IDs. -**SearchCoordinator debounce task lifecycle**: `scheduleSearchUpdate()` cancels the previous `searchDebounceTask` before creating a new one. The `Task.sleep` is followed by a `Task.isCancelled` check to handle cancellation during the sleep. The `setActiveSearchQueryForTesting` method bypasses this entirely for synchronous testing, cancelling any in-flight task first.+**SearchCoordinator debounce task lifecycle**: `scheduleSearchUpdate()` cancels the previous `searchDebounceTask` before creating a new one. The `Task.sleep` is followed by a `Task.isCancelled` check to handle cancellation during the sleep. The `setActiveSearchQueryForTesting` method bypasses this entirely for synchronous testing. It cancels the in-flight task *after* assigning `searchQuery`, because that assignment's `didSet` schedules a fresh debounce task of its own (T-1960). **TOCCoordinator lazy cache with `@Observable`**: The `tocEntries` getter mutates `_cachedTOCEntries` on first access. This is safe with `@Observable` — the mutation fires one observation event (on first access), subsequent reads return the cached value without mutation. The same pattern existed in the original `DocumentSession` and in `DetailsExpansionCoordinator`.
Verified mechanically, not just asserted. PR #322 (T-1775/bugfix-fragment-navigation-persisted-scroll, MERGEABLE) adds three tests that each call session.search.setActiveSearchQueryForTesting(…), then navigateToMatch(at: 0), then try #require(session.currentMatch?.blockIndex), then await waitUntil { controller.latestSnapshot.scrollTargetBlockID == matchID } — multi-second polling in the exact window where the leaked task nil'd the cursor and collapsed session.currentMatch to nil. The mechanism matches the reported breakage. Still confirm on #322's own branch after this merges — that PR touches the WebKit scroll path and could have independent failures this fix does not address.
The specific worry with a harness fix is trading a real regression for a green suite. Two checks argue against it here. First, the fix removes a state mutation rather than adding tolerance — no assertion was weakened and no expectation was retargeted to match observed behaviour. Second, the one existing test that changed had its vacuous assertion made meaningful (nil→nil became 0→nil), which is the opposite of masking. The seam audit across all 41 helper call sites found no other test whose expectations shift.
Two runs of DocumentSessionTests + WebSearchWiringTests + SearchCoordinatorTests produced different failure sets: run 1 gave settingValidSearchQuerySchedulesDebouncedUpdate and footnoteDataMatchesLatestReloadAfterConcurrentReloads; run 2 gave concurrentReloadsApplyOnlyLatestContent and rapidSequentialReloadsConvergeToLatestState. All pass in isolation. Three of the four live in DocumentSessionParseGenerationTests and never touch search at all. The fourth, settingValidSearchQuerySchedulesDebouncedUpdate, sleeps 250 ms against a 200 ms debounce — 50 ms of headroom, which concurrent xcodebuild load eats; it is also provably ordering-independent, since its subsequent direct searchQuery write cancels any leftover task via scheduleSearchUpdate in both old and new orderings. That thin-margin test is a standing flake worth a ticket.
PR #324 is MERGEABLE/CLEAN with all four checks green — File Checks, SwiftLint, Stylelint, Per-locale test sweep. None of them builds the app or runs the unit tests. Local verification is the only real signal: SwiftLint 0/497, make build-ios Build Succeeded, SearchCoordinatorTests 49/49 on macOS.
Three comment/doc corrections were applied during this review and sit in the working tree: prism/Services/SearchCoordinator.swift (docstring scope), prismTests/SearchCoordinatorTests.swift (ticket attribution), specs/document-session-extraction/implementation.md (stale sentence). They must be committed before pushing. Lint and the full suite were re-run after them — both clean. A fourth correction went to the project memory note outside the repo and is not part of the branch.