Skip to content

fix(board): bound and abort the shared board-list read, one source for the filter defaults - #2688

Merged
Chris0Jeky merged 5 commits into
mainfrom
issue-2685/list-read-bound
Sep 5, 2026
Merged

fix(board): bound and abort the shared board-list read, one source for the filter defaults#2688
Chris0Jeky merged 5 commits into
mainfrom
issue-2685/list-read-bound

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

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.fetchBoards called
boardsApi.getBoards(search, includeArchived) with no signal, no timeout and the retry
interceptor engaged, and the axios instance in api/http.ts 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 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_MS and skipRetry: true.

Worst-case wall time before this change, read from api/httpRetry.ts: a 503 carrying
Retry-After costs MAX_RETRIES = 3 waits, each clamped by parseRetryAfter to
MAX_DELAY_MS = 60 000 ms, so 180 seconds of pure backoff across 4 attempts, and each of
those 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.

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.
Each unsettled list read now registers an AbortController, and the reset aborts them all
after 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 CardFilters defaults lived as a literal in three places. One exported
initialCardFilters() replaces all three.

Closes #2685
Refs #1961, PR #2681

Changes

fix(board): bound the shared board-list read like the detail read (29bfc5b)

  • fetchBoards passes { signal, timeout: BOARD_REQUEST_TIMEOUT_MS, skipRetry: true } to
    boardsApi.getBoards, matching startBoardFetch.
  • Filtered reads take the same bound rather than a weaker one. They hit the same endpoint, so a
    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 its
    failure to this store's error surface rather than to the retry layer. Retries do not help the
    wedged-socket case at all.
  • Existing getBoards argument assertions in the spec now allow the options argument.

fix(board): abort in-flight board-list reads on logout (083cada)

  • A controller set tracks every unsettled list read, filtered or not, because list reads do not
    supersede each other the way the detail read does; each request removes its own controller
    when it settles.
  • resetForLogout bumps boardListGeneration first, 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 leaves state.loading to whoever owns it next.
  • The catch also returns on axios.isCancel(e), the same belt-and-braces the detail path
    carries. That arm is unreachable today because the only aborter bumps the generation first.
  • Item 4 of the issue is recorded beside the finally gate: 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. The comment names the failure a list-side helper
    modelled on cancelBackgroundBoardFetch would 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 from boardState.ts, called by createBoardState,
    cardFilterStore.clearFilters and boardCrudStore.resetForLogout.
  • A function rather than a shared constant: labelIds is an array, so one exported instance
    would 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.ts factory-mocks api/http with only the
    default export. The detail read already imported the named BOARD_REQUEST_TIMEOUT_MS, but no
    case 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/** and src/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.ts
    Result: Test Files 6 passed, Tests 156 passed.
  • npx vitest --run --maxWorkers=2 (whole unit suite): Test Files 375 passed (375), Tests 5872
    passed, 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.ts double gap was found, and the six-file command above does not
    reach it.
  • npm run typecheck: clean.
  • npx eslint on 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 retries and
    bounds a filtered list read the same way as the shared one: the toMatchObject on
    getBoards.mock.calls[0][2] received undefined, and the filtered spec's
    toHaveBeenCalledWith('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 a
    behaviour-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 settles and
    aborts an in-flight filtered board-list read as well: expect(signal.aborted).toBe(true)
    received false after resetForLogout().
  • resets filters to a fresh initialCardFilters() instance,
    clears to the same value the store's initial state starts from and
    returns 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 resolves and ... rejects, are unchanged and green.

NOT verified:

  • No browser or E2E run. The user-visible consequence of skipRetry on a real transient 503,
    and the real 10 s timeout firing against a wedged socket, are argued from api/http.ts and
    api/httpRetry.ts rather than observed.
  • No backend run; nothing backend changed.
  • npm run build was not run; npm run typecheck was.

Boundaries and risks

  • skipRetry changes 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.
  • Filtered reads take the same bound, which reaches one caller outside the boards list:
    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.
  • On the store side the error-and-retry path stays honest: the share slot is released on
    rejection, and lastFetchBoardsAt is only stamped on success, so the next fetchBoards()
    issues a fresh request with no throttle wait. On the view side, PaperTriageTable has an
    explicit Retry control; BoardsListView renders boardStore.error in a role="alert" block
    and has no Retry button today, so recovery there is a remount or navigation. No view was
    changed in this PR.
  • Four callers reach boardsApi.getBoards directly rather than through the store:
    views/ArchiveView.vue, composables/useAutomationChat.ts,
    composables/useProposalDisplayNames.ts, composables/useReviewProposals.ts. They are
    unchanged and remain unbounded. Bounding them is outside a store-only change.
  • The axios.isCancel arm added to the fetchBoards catch is unreachable today, since the only
    aborter bumps the generation first. It is defence for a future aborter that forgets the bump.
  • Scope: src/store/board/** and src/tests/store/board/**, plus the one-line test double in
    src/tests/store/boardStore.integration.spec.ts described above. No API-layer, view, AppShell
    or documentation change.

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

…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.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane, review-and-ship round 1 at head 82c80b1c1; comment-and-test-only fixes at effdd122b).

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 loading is cleared in the same synchronous turn, so the finally gate's stated coupling holds; an aborted read returns at the generation guard before handleApiError, so no toast or error write follows the user to login; the controller set is created after the early returns and removed in finally on every outcome; the shared promise carries its own rejection handler and the unshared filtered read's only caller catches; timeout is per attempt and skipRetry makes it a hard 10 s ceiling; two joiners share one handleApiError; the 401 interceptor is unchanged; initialCardFilters() allocates a fresh object and array per call at all three sites with the type unchanged; the integration-spec double matches the real export, and of the 46 specs that factory-mock api/http only that one also drives a store read. Confirmed locally at the head: the reviewer's suggested command over the two store specs, the integration and column-reorder specs and the two resilience specs, 6 files, 154 passed.

Recommendation on the bound, adopted: keep skipRetry: true. Timeout-only would be bounded too (about 47 s for a plain transient, about 220 s under a server Retry-After), but under the share a retry chain does not lengthen one mount's skeleton, it pins the boards list, the inbox composer, the triage picker, Metrics and Saved Views together, the page-wide stall #2685 exists to remove, only with a ceiling; and the detail read already made the same choice. The PR's comment gave the wrong reason ("retries do not help the wedged socket", true but answered by timeout alone); it now states the share-amplified reason and the cost.

Findings, triaged once:

Checks at effdd122b: boardCrudStore.spec.ts 65 of 65, eslint and typecheck clean, git diff --check clean. Round count: 2 (comment and test text only; no logic changed, so no second pass is owed). Merge gate remaining: CI green at effdd122b and the three-minute age.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

CI at effdd122b was red on Frontend Unit (windows-latest) at the step "Run source launcher regression suite": scripts/ci/dev-up.test.mjs:1660 (PowerShell: invalid Vite outcome duplicate cleans both trees and never reports success) failed at assertNoStateAndPortsReleased with the retained-state signature tracked as #2561 (the platform lane's dev-up cohort; the same signature hit PR #2671 at 08:45Z today). This PR changes frontend board-store files and their specs only and cannot reach that seam. Occurrence recorded on #2561; the failed job was re-run once (run 33966116932, attempt 2). Merge waits for that attempt's conclusion at the same head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Frontend][Board] Bound and abort the shared board-list read; dedupe the CardFilters defaults (follow-ups from the #2681 review)

1 participant