Skip to content

0.8.0

Latest

Choose a tag to compare

@ramil-top ramil-top released this 03 Sep 15:41
716f749
Inject URLSession into the transports and drop the shared delegate (#18)

* Inject URLSession into the transports and drop the shared delegate

URLSession retains its delegate until `invalidate`, so a session-level
delegate closed a retain cycle: the transport owned the session, the
session retained the delegate, the delegate held the transport. Since a
transport was built per API client, every client leaked a session and
they accumulated without bound. The `lazy var session` compounded it:
`lazy var` is not atomic, so two concurrent sends could initialise it
twice and release the first value out from under an in-flight request.

Sessions are now injected. The transports own nothing, so there is
nothing to invalidate and a caller can share one session across clients
and keep connection reuse.

- `URLSessionTransport` — one-shot requests via `data(for:)`; the
  session owns task start, cancel and Task-cancellation propagation.
- `StreamURLSessionTransport` — SSE only, on a per-task delegate
  (iOS 15+) instead of a session-level one. The task retains its own
  delegate until completion, so no shared dictionary and no cycle.

Response and body are delivered as `AsyncThrowingStream`s, which
removes the hand-rolled buffering and its lock: `yield` is synchronous
and ordered, and `finish` after `finish` is a no-op, so exactly-once
completion is the stdlib's bookkeeping. `task.resume()` runs before
`withTaskCancellationHandler` is installed, so `cancel` cannot overlap
`resume` by program order rather than by serialisation.

Defects found along the way and fixed here:
- chunks were forwarded through a fresh `Task` each, and hops carry no
  ordering guarantee, so bytes could be reordered;
- a non-HTTP response cancelled the task without resuming the
  continuation, hanging the caller permanently;
- the task handler was never removed on the error path;
- `EventParser` rescanned the whole buffer after every append, so
  assembling one event cost time quadratic in its length;
- `EventSourceDecodableErrorParser` never released its buffer, growing
  without bound on the long-lived TonConnect bridge; it is now capped,
  keeping the bytes for the one paired decode before releasing them.

Adds tests for both parsers. Raises the platform floor to iOS 15, which
the per-task delegate requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Report cancellation instead of a missing response

Cancelling the surrounding Task finishes the response stream's iterator
with `nil` rather than an error: `AsyncThrowingStream`'s own cancellation
handler resumes the pending continuation at once, well before ours can
round-trip through CFNetwork and let the delegate finish the stream. The
`guard` therefore reported every cancellation as `noResponse`, and would
also have handed back a response that arrived just before the
cancellation to a caller that no longer wanted it.

Adds a `URLProtocol`-stubbed test target for the transport covering the
cancellation path, response and byte-order delivery, and the non-HTTP
response that used to hang the caller. The fourth case — a failure
arriving after the response must terminate only the body stream — is
driven through the delegate instead: a stubbed loader discards a
response it has already queued when the protocol then fails, so it
cannot deliver that pair in that order at all.