fix(network): dedicated URLSession for BTW streaming (LUM-820, LUM-903)#27250
Conversation
There was a problem hiding this comment.
✦ APPROVE
Value: Fixes real EXC_BAD_ACCESS crashes affecting 4+ users (Sentry MACOS-EB) by eliminating the use-after-free race between Task.cancel() and the AsyncBytes iterator on URLSession.shared.
What this does: Ports the proven PR #25396 per-call URLSession pattern from EventStreamClient to the remaining streaming paths — streamPost and streamPostWithRetry in GatewayHTTPClient. BtwClient creates a dedicated session per sendMessage call and invalidates it via defer when streamBtw returns.
Verified:
- ✅ Session lifecycle is correct —
defer { session.invalidateAndCancel() }fires AFTERfor try await line in bytes.linescompletes instreamBtw, so bytes are fully consumed before teardown - ✅ Cancellation path is clean —
continuation.onTerminationcancels the Task →Task.isCancelledbreaks the loop → function returns → defer fires - ✅ Retry path at line 549 correctly reuses the same dedicated session (not
.shared) - ✅ Default parameter (
session: URLSession = .shared) preserves backward compatibility for any future callers - ✅
BtwClientis the only external caller ofstreamPostWithRetry, andstreamPosthas no direct external callers — all vulnerable streaming paths are covered - ✅ Pattern matches
EventStreamClient(PR #25396) exactly, just scoped to function lifetime instead of object lifetime (correct for BTW's request-response pattern vs SSE's long-lived connection)
Testing note: Exercise a BTW side-chain message (inline "btw" question) and verify the streamed response renders. Also test cancelling mid-stream (navigate away during a BTW response) to confirm clean teardown.
Summary
URLSessionpattern forstreamPostandstreamPostWithRetry.BtwClientnow creates a dedicatedURLSessionpersendMessagecall and invalidates it when the stream finishes (including on error or consumer cancellation).GatewayHTTPClient.streamPostandstreamPostWithRetrygain asession: URLSession = .sharedparameter — default-valued so existing callers are unaffected.Why
Running streaming
URLSession.bytes(for:)onURLSession.sharedracesTask.cancel()against theAsyncBytescooperative-pool iterator. When the shared session's internal state is torn down while the iterator is still reading,CheckedContinuation.resumehits freed memory and the process crashes withKERN_INVALID_ADDRESS at 0x28inNSURLSession.data→__NSCFURLSessionDelegateWrapper→_task_onqueue_didFinish.The fix in #25396 (EventStreamClient SSE) moved the main event stream to a per-connection session so
invalidateAndCancel()can tear down the data task on its own terms. This PR ports the same pattern to the remaining streaming paths (streamPost,streamPostWithRetry) and to their sole caller,BtwClient.Tickets resolved
CheckedContinuation.resumeduringURLSession.datacallbackstreamPost/streamPostWithRetrystill useURLSession.sharedDeferred to LUM-1001
A separate Devin session is landing
SafeAsyncBytes— a delegate-based async bytes wrapper that survives concurrent session invalidation. That's a larger change; this PR intentionally stops at the minimal #25396 pattern to unblock users on the crash signal now. The two are compatible: the session parameter added here carries through.Testing
🤖 Generated with Claude Code