Skip to content

Fix requests hanging ~2 minutes and disconnect not working on a dead server - #258

Merged
taylorcox75 merged 1 commit into
release/v3.8.45from
bugfix/#254-request-timeout-and-cancellation
Sep 20, 2026
Merged

taylorcox75 merged 1 commit into
release/v3.8.45from
bugfix/#254-request-timeout-and-cancellation

Conversation

@taylorcox75

Copy link
Copy Markdown
Owner

Summary

Fixes #254. Reporter's repro: connect over Wi-Fi, switch to mobile so the server becomes unreachable, open torrent detail or Transfer — infinite loading, and the disconnect button doesn't work.

Root cause chain:

  1. Only get() retried (withRetry), with 4 attempts (retryAttempts defaults to 3) each carrying the full axios timeout (default 10s), plus 500/1000/1500ms backoff between them — ≈43s per logical GET against a dead server.
  2. TanStack Query's own retry: 2 on the torrent/transfer polling queries stacked another 3x on top — ≈129s (~2 min) before any error surfaced, and the polls kept restarting so the UI never settled.
  3. ServerManager.disconnect() awaited a logout POST with nothing to cancel it if the server was unreachable, and nothing aborted the in-flight GETs — they landed afterward.

What changed:

  • services/api/client.ts
    • withRetry now treats connectionTimeout as a total budget for the whole logical request rather than a per-attempt allowance: each attempt's axios timeout is capped to the remaining budget (with a 1s floor so the last attempt isn't handed ~0ms), and a retry is skipped rather than slept if the backoff alone would blow the deadline. Net effect: a GET against a dead server now fails in ~connectionTimeout, not ~43s.
    • Added a session-scoped AbortController (abortInFlight()), used by default in get/post/postFormData/postUrlEncoded whenever the caller doesn't pass its own signal, and wired into setServer()/clearCookies() so every session teardown (server switch, disconnect) cancels in-flight requests.
    • ERR_CANCELED now gets its own normalized message ("Request canceled.") instead of falling into the generic branch. It was already correctly excluded from retry (isRetriableError) and from RECONNECTABLE_MESSAGES (hooks/useReactiveReconnect.ts) — this just makes the thrown error identifiable rather than reusing the timeout message.
  • services/server-manager.ts — disconnect() no longer awaits logout() to completion; it fires it (errors ignored, as before) and calls apiClient.abortInFlight() immediately, so disconnect returns promptly instead of waiting out a hung logout against an unreachable server.
  • context/TorrentContext.tsx, context/TransferContext.tsx — set retry: 0 on the two polling queries (refetchInterval 2s/3s). The client already retries/backs off internally; a query-level retry on top of that was the multiplier that turned a bounded client-side failure into ~2 minutes. Did not touch the global default in services/query-client.ts — other one-shot queries rely on it.

Per AGENTS.md scope discipline, app/(tabs)/(torrents)/index.tsx (Task B territory) was not touched.

Test plan

  • npx tsc --noEmit — exit 0
  • npm test — 78/78 suites, 1140/1140 tests passing (added coverage in tests/services/client.test.ts for the retry budget/floor behavior, ERR_CANCELED not being retried and getting its own message, and abortInFlight() cancelling in-flight get/post/postFormData/postUrlEncoded calls that didn't pass their own signal — including that a caller-supplied signal is left untouched; plus tests/services/server-manager.test.ts coverage that disconnect() doesn't hang on a never-resolving logout)
  • npm run lint — 0 errors, 37 warnings (documented baseline)
  • npm run format — clean
  • Could not run the app (no simulator/device in this environment) — this is a pure logic/timing fix with no UI change, so nothing here needed visual verification, but the owner may still want to confirm the real-device repro (Wi-Fi → airplane/mobile → open Transfer, then tap disconnect) resolves.

🤖 Generated with Claude Code

…server

A GET against an unreachable server used to take ~43s (4 retry attempts x
the full 10s axios timeout, plus 500/1000/1500ms backoff) and, with
TanStack Query's own retry: 2 stacked on top for the torrent/transfer
polls, ~2 minutes before anything surfaced. Disconnect additionally
awaited a logout POST with nothing to cancel it, so it looked broken too.

- services/api/client.ts: withRetry now treats connectionTimeout as a
  total budget for the whole logical request instead of a per-attempt
  allowance, capping each attempt's axios timeout to the remaining budget
  (with a 1s floor) and skipping a retry whose backoff would blow the
  deadline. Added a session-scoped AbortController (abortInFlight()),
  used by default in get/post/postFormData/postUrlEncoded when the caller
  doesn't pass its own signal, and wired into setServer()/clearCookies()
  so every session teardown cancels in-flight requests. ERR_CANCELED now
  gets its own normalized message instead of falling into the generic
  branch — it was already correctly excluded from retry and from
  RECONNECTABLE_MESSAGES, this just makes it identifiable.
- services/server-manager.ts: disconnect() no longer awaits logout() —
  it fires it and calls apiClient.abortInFlight() so a hung logout can't
  block the disconnect button.
- context/TorrentContext.tsx, context/TransferContext.tsx: set retry: 0
  on the two polling queries, since the client already retries/backs off
  and a 2-3s poll gains nothing from TanStack retrying on top.

Fixes #254
@taylorcox75
taylorcox75 merged commit 0943a3f into release/v3.8.45 Sep 20, 2026
1 check passed
@taylorcox75
taylorcox75 deleted the bugfix/#254-request-timeout-and-cancellation branch September 20, 2026 23:58
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