fix(board): bound and abort the shared board-list read, one source for the filter defaults - #2688
Conversation
fetchBoards issued getBoards with no signal, no timeout and the retry interceptor engaged, while the axios instance sets no default timeout. Once PR #2681 gave every unfiltered caller one shared promise, a read that never settled pinned BoardsListView, MetricsView, SavedViewsView, PaperCaptureComposer and PaperTriageTable until logout rather than failing a single mount. A 503 with Retry-After cost three waits of up to MAX_DELAY_MS (60s) each before any caller heard anything. Pass the detail read's contract instead: an AbortSignal, timeout: BOARD_REQUEST_TIMEOUT_MS and skipRetry: true. Filtered reads take the same bound rather than a weaker one, so the retry policy does not change with a query parameter on the same endpoint.
resetForLogout bumped the list generation and dropped the share slot, so a late response was discarded, but the previous account's request stayed on the wire until the server answered it. Track every unsettled list read in a controller set and abort them all in the reset, after the generation bump so each rejection finds a stale generation and the catch returns before handleApiError: no toast follows the user to the login screen. Both lifecycles are now aborted, list and detail. Also record beside the fetchBoards finally gate the coupling the PR #2681 round-2 verification named: the gate is correct only while every bumper of boardListGeneration clears state.loading in the same synchronous turn, and resetForLogout is the only bumper today.
…sites createBoardState, cardFilterStore.clearFilters and boardCrudStore.resetForLogout each carried the CardFilters literal. The type annotation makes an added field a compile error, so the drift risk was a changed default reaching only some of the three. Export one initialCardFilters() from boardState.ts and call it at all three sites. It is a function, not a shared constant: labelIds is an array, so a single exported instance would let a filter change in one board reach the value the next reset restores.
boardStore.integration.spec.ts factory-mocks api/http with only the default export. The detail read already imported the named BOARD_REQUEST_TIMEOUT_MS, but nothing in this spec reached it; bounding the list read does, so all 22 of its cases hit vitest's missing-export error. Add the constant to the double.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…op a vacuous identity assertion Answers the fresh-context review of PR #2688: the comment beside the bound said retries do not help the wedged socket, which is true but is not why skipRetry is set (timeout alone fixes that case); the reason is the share, under which a retry chain would pin every unfiltered caller on the page together. The reset spec's not.toBe(initialCardFilters()) could never fail because every call allocates; the mutation-leak check beside it is the real assertion and stays.
|
Review record (alpha product-trust lane, review-and-ship round 1 at head Reviewer: one fresh-context independent reviewer subagent (read-only; Codex credits exhausted, SC-9), given the worktree at the head and the merge-base diff. Verdict: SHIP, no merge-blocking items. Lenses that found nothing: the reset bumps, aborts, then clears, the list generation is bumped at exactly one site and Recommendation on the bound, adopted: keep Findings, triaged once:
Checks at |
|
CI at |
Summary
Three follow-ups from the fresh-context review of PR #2681, all inside the board store.
The shared unfiltered board-list read was unbounded.
boardCrudStore.fetchBoardscalledboardsApi.getBoards(search, includeArchived)with no signal, no timeout and the retryinterceptor engaged, and the axios instance in
api/http.tssets no default timeout. OncePR #2681 gave every unfiltered caller one shared promise, a read that never settled pinned
BoardsListView, MetricsView, SavedViewsView, PaperCaptureComposer and PaperTriageTable at once
instead of failing a single mount. This PR gives the list read the contract the detail read
already has: an
AbortSignal,timeout: BOARD_REQUEST_TIMEOUT_MSandskipRetry: true.Worst-case wall time before this change, read from
api/httpRetry.ts: a 503 carryingRetry-AftercostsMAX_RETRIES= 3 waits, each clamped byparseRetryAftertoMAX_DELAY_MS= 60 000 ms, so 180 seconds of pure backoff across 4 attempts, and each ofthose 4 attempts is itself unbounded because no timeout is set. A socket that is accepted and
never answered never settles at all, so the true worst case before is unbounded and every
joined caller waits for it. After this change the read is one attempt bounded at 10 000 ms.
resetForLogoutbumped the list generation and dropped the share slot, so a late response wasdiscarded, but the previous account's request stayed on the wire until the server answered.
Each unsettled list read now registers an
AbortController, and the reset aborts them allafter the generation bump, so the rejection each abort produces finds a stale generation and
the catch returns before
handleApiError: no toast follows the user to the login screen.The
CardFiltersdefaults lived as a literal in three places. One exportedinitialCardFilters()replaces all three.Closes #2685
Refs #1961, PR #2681
Changes
fix(board): bound the shared board-list read like the detail read(29bfc5b)fetchBoardspasses{ signal, timeout: BOARD_REQUEST_TIMEOUT_MS, skipRetry: true }toboardsApi.getBoards, matchingstartBoardFetch.retry policy that changed with a query parameter would be a trap for the next caller, and the
only in-store filtered caller (
useActivityQuery.loadSelectorData) already delegates itsfailure to this store's error surface rather than to the retry layer. Retries do not help the
wedged-socket case at all.
getBoardsargument assertions in the spec now allow the options argument.fix(board): abort in-flight board-list reads on logout(083cada)supersede each other the way the detail read does; each request removes its own controller
when it settles.
resetForLogoutbumpsboardListGenerationfirst, then aborts every registered controller.The ordering is what keeps the abort silent: the catch's generation guard returns before
handleApiError, and the finally gate leavesstate.loadingto whoever owns it next.axios.isCancel(e), the same belt-and-braces the detail pathcarries. That arm is unreachable today because the only aborter bumps the generation first.
bumper of
boardListGenerationclearsstate.loadingin the same synchronous turn, andresetForLogoutis the only bumper today. The comment names the failure a list-side helpermodelled on
cancelBackgroundBoardFetchwould cause and names the ownership-token alternative.The gate itself is unchanged, so the two round-2 specs from PR fix(board): share one in-flight board-list fetch and reset the board store on logout #2681 are untouched.
refactor(board): one initialCardFilters() for the three filter-reset sites(377dcce)initialCardFilters()exported fromboardState.ts, called bycreateBoardState,cardFilterStore.clearFiltersandboardCrudStore.resetForLogout.labelIdsis an array, so one exported instancewould let a filter change in one board reach the value the next reset restores.
test(board): carry BOARD_REQUEST_TIMEOUT_MS in the http test double(82c80b1)src/tests/store/boardStore.integration.spec.tsfactory-mocksapi/httpwith only thedefault export. The detail read already imported the named
BOARD_REQUEST_TIMEOUT_MS, but nocase in that spec reached it; bounding the list read does, so all 22 of its cases hit vitest's
missing-export error. The constant is added to the double. This is the one file touched outside
src/store/board/**andsrc/tests/store/board/**, and it is a test double the change broke,not a behaviour change.
Test plan
Verified, from
frontend/taskdeck-web:npx vitest --run --maxWorkers=2 src/tests/store/board/boardCrudStore.spec.ts src/tests/store/board/cardFilterStore.spec.ts src/tests/store/boardStore.spec.ts src/tests/resilience/slowApiAndStorage.spec.ts src/tests/property/storeResilience.spec.ts src/tests/views/paper/boardMutationCapabilityParity.spec.tsResult: Test Files 6 passed, Tests 156 passed.
npx vitest --run --maxWorkers=2(whole unit suite): Test Files 375 passed (375), Tests 5872passed, 3 skipped (5875). An earlier run of the same command, before the fourth commit, was
Test Files 1 failed / 374 passed and Tests 11 failed / 5861 passed: that is how the
boardStore.integration.spec.tsdouble gap was found, and the six-file command above does notreach it.
npm run typecheck: clean.npx eslinton all six changed files: clean.git diff --check: clean.Red-first. Each new assertion was run against the store as it stood before its own commit:
bounds the shared unfiltered list read with a signal, a timeout and no retriesandbounds a filtered list read the same way as the shared one: thetoMatchObjectongetBoards.mock.calls[0][2]receivedundefined, and the filtered spec'stoHaveBeenCalledWith('urgent', false, expect.anything())reported the third argument missing.surfaces a timed-out shared read once, releases the share, and lets the next caller refetch:the same options assertion received
undefined. The rest of that spec (both joiners reject,one
handleApiError, loading cleared, share released, retry issues a new request) is abehaviour-preservation assertion, not a red one: it passed before as well, on an error the
bound is what makes reachable.
aborts the in-flight board-list read and writes nothing when it settlesandaborts an in-flight filtered board-list read as well:expect(signal.aborted).toBe(true)received
falseafterresetForLogout().resets filters to a fresh initialCardFilters() instance,clears to the same value the store's initial state starts fromandreturns a fresh object every call so one site cannot mutate another:TypeError: initialCardFilters is not a function.The two round-2 specs from PR #2681,
leaves a newer read loading when a superseded response resolvesand... rejects, are unchanged and green.NOT verified:
skipRetryon a real transient 503,and the real 10 s timeout firing against a wedged socket, are argued from
api/http.tsandapi/httpRetry.tsrather than observed.npm run buildwas not run;npm run typecheckwas.Boundaries and risks
skipRetrychanges how a transient 503 or a network blip surfaces on the boards list. Before,the retry layer waited and often recovered without the user seeing anything, at a cost of up to
three minutes of silence. Now the first failure is terminal and the user sees the store's error
state. That is the trade the issue asked to be made deliberately, and it is the contract the
board detail read already carries in
startBoardFetch.useActivityQuery.loadSelectorData(the ActivityView board selector,fetchBoards(undefined, true)).It catches and delegates its failure to this store's error surface, so it does not break, but it
loses the retry layer's silent recovery too.
rejection, and
lastFetchBoardsAtis only stamped on success, so the nextfetchBoards()issues a fresh request with no throttle wait. On the view side,
PaperTriageTablehas anexplicit Retry control;
BoardsListViewrendersboardStore.errorin arole="alert"blockand has no Retry button today, so recovery there is a remount or navigation. No view was
changed in this PR.
boardsApi.getBoardsdirectly rather than through the store:views/ArchiveView.vue,composables/useAutomationChat.ts,composables/useProposalDisplayNames.ts,composables/useReviewProposals.ts. They areunchanged and remain unbounded. Bounding them is outside a store-only change.
axios.isCancelarm added to thefetchBoardscatch is unreachable today, since the onlyaborter bumps the generation first. It is defence for a future aborter that forgets the bump.
src/store/board/**andsrc/tests/store/board/**, plus the one-line test double insrc/tests/store/boardStore.integration.spec.tsdescribed above. No API-layer, view, AppShellor documentation change.