Commit f0b086f
authored
Recover live requests when fetch hangs (#4674)
## Summary
- Add a `ShapeStream` request watchdog (`liveRequestTimeoutMs`, default
`45_000`) so wedged mobile fetch promises cannot permanently stop the
read loop.
- Apply the watchdog to both live long-poll requests and refresh
catch-up requests, after Hudson’s PR-build test showed non-live catch-up
requests can also hang after backgrounding.
- Make watchdog restarts independent of platform abort behavior by
marking restart intent on the request controller, aborting with
`live-request-timeout`, and racing the fetch promise with a local
rejection.
- Fix wake recovery to keep `isRefreshing` true until the next
request-loop tick completes, ensuring foreground/system-wake recovery
performs non-live catch-up requests even when URL construction yields.
- Make retry backoff abort-aware at sleep entry and document the
request-loop invariants in `SPEC.md`.
## Context
This follows the React Native/mobile issue where `awaitTxId` can time
out permanently after backgrounding. Hudson reproduced a read-loop
variant directly:
- app ran normally for ~21 minutes, with an earlier 2 minute
background/foreground cycle working fine
- app then backgrounded for ~6.5 minutes while the backend inserted new
Postgres rows
- after foregrounding, those backend rows never appeared on the client
- subsequent writes succeeded server-side and returned valid txids, but
every tx echo timed out
- the app did not recover until kill/restart
That points at the client read loop getting wedged: once the request
loop stops settling, the client observes neither backend-generated rows
nor mutation tx echoes.
Hudson then tested this PR build on a physical iPhone with Electric
request logging. The abort/reconnect path worked: all shape streams
aborted after backgrounding and sent non-live catch-up requests.
However, polling stopped at those catch-up requests and no further
Electric requests were logged. This update extends the watchdog to those
refresh catch-up requests as well, so a hung catch-up fetch can no
longer stop the loop permanently.
## Changes
### Request watchdog
`ShapeStreamOptions` now includes:
```ts
liveRequestTimeoutMs?: number | false
```
- defaults to `45_000`
- applies to live long-poll requests (`live=true`) and refresh catch-up
requests
- accepts only positive finite numbers, or `false` to disable
- aborts timed-out requests with `live-request-timeout`
- rejects locally so the loop can continue even if the runtime fetch
ignores abort or never settles
### Wake recovery
Wake detection now mirrors `forceDisconnectAndRefresh()` by holding the
refresh flag until the next request-loop tick completes. This prevents
an async `#constructUrl()` yield from clearing `isRefreshing` before
`canLongPoll` is computed, which could otherwise reconnect with another
`live=true` long-poll instead of a catch-up request.
### Retry backoff
`abortableSleep()` now throws `FetchBackoffAbortError` when entered with
an already-aborted signal, instead of silently returning and allowing
retry flow to continue as though the sleep completed normally.
## Tests
```sh
cd packages/typescript-client
pnpm run typecheck
pnpm exec vitest --config vitest.unit.config.ts --run test/fetch.test.ts test/wake-detection.test.ts
```
Passed locally:
- typecheck
- `test/fetch.test.ts`: 39 tests
- `test/wake-detection.test.ts`: 10 tests1 parent b8be884 commit f0b086f
8 files changed
Lines changed: 1061 additions & 82 deletions
File tree
- .changeset
- packages/typescript-client
- src
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
Large diffs are not rendered by default.
0 commit comments