Skip to content

fix(macos): replace URLSession.bytes with delegate-driven stream to survive session invalidation#26281

Closed
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
devin/lum-1001-1776475015-sse-session-nsgeneric-exception
Closed

fix(macos): replace URLSession.bytes with delegate-driven stream to survive session invalidation#26281
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
devin/lum-1001-1776475015-sse-session-nsgeneric-exception

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Replaces URLSession.bytes(for:) inside GatewayHTTPClient's streaming entry points (stream, streamPostWithRetry) with a URLSessionDataDelegate-driven SafeAsyncBytes where we own the dataTask(with:) call and wrap it in the existing VLMPerformWithObjCExceptionHandling ObjC @try/@catch trampoline. An invalidated session now surfaces a Swift URLError(.cancelled) instead of aborting the process with SIGABRT. Also adds a session: URLSession = .shared parameter to streamPostWithRetry so it matches stream() and can participate in the dedicated-session lifecycle pattern established in #25396, and deletes the zero-call-site streamPost non-retry variant (dead since #19442).

Why

The app crashes (LUM-1001) with NSGenericException: Task created in a session that has been invalidated at EventStreamClient.swift:313 → GatewayHTTPClient.swift:465. PR #25396 moved SSE to a dedicated URLSession per connection, and PR #25426 added a sseSession === session guard — but that guard runs on MainActor before the await GatewayHTTPClient.stream(...) call. Because the network layer is intentionally non-isolated (PR #21729 / LUM-492), that await hops off MainActor; while suspended, another MainActor turn (token rotation, explicit stopSSE+startSSE, reconnect) can invalidate the captured local session. When control resumes on the concurrent executor, URLSession.bytes(for:) synchronously calls -[NSURLSession dataTaskWithRequest:] on the now-invalidated session, which raises an ObjC exception Swift do/catch cannot intercept.

The only way to fully close this is to own the dataTask(with:) call site so it can live inside an ObjC @try/@catch — which is what this PR does.

Benefits

  • Eliminates the SIGABRT crash path entirely — an invalidated session now throws URLError(.cancelled) into the caller's existing error-handling code (EventStreamClient.startSSEStreamhandleSSEDisconnect() → normal reconnect).
  • Closes three related concurrency gaps along the way: parent-task cancellation during header wait, late setResponseContinuation vs. delegate-completion race, and unbounded buffering for high-volume consumers.
  • Preserves the public API for callers: .lines still works because SafeAsyncBytes: AsyncSequence where Element == UInt8.

Why it's safe

  • Happy path unchanged. The delegate drives the same byte stream callers already consume via bytes.lines. The only observable difference is the return type (source-compatible for current usage) and a conservative TCP backpressure policy that only activates on >1 MiB of queued bytes.
  • Invalidation path is strictly better. The former behaviour was SIGABRT; the new behaviour is a Swift-catchable URLError.
  • Cancellation matches URLSession.bytes(for:) semantics. withTaskCancellationHandler propagates parent-task cancellation to URLSessionDataTask.cancel().
  • Deployment targets are comfortably ahead of what the new APIs need. Per-task URLSessionTask.delegate requires iOS 15+ / macOS 12+; the project targets iOS 17 / macOS 15 (clients/Package.swift).
  • No retain cycles. StreamingBytesDelegate.task is weak, and bytesConsumed captures [weak self].

What changed

  • GatewayHTTPClient.safeBytes(session:request:) — new private helper. Creates the data task inside VLMPerformWithObjCExceptionHandling (the ObjC trampoline in clients/shared/ObjCExceptionCatcher, already used for AVAudioNode.installTap). Attaches a per-task URLSessionDataDelegate that feeds an AsyncThrowingStream<Data, Error> of chunks. Resumes the task (also inside the trampoline, defensively) and awaits the initial URLResponse via a CheckedContinuation wrapped in withTaskCancellationHandler. On any failure, chunksContinuation.finish(throwing:) propagates the error to the iterator.
  • GatewayHTTPClient.SafeAsyncBytes — new public AsyncSequence with Element == UInt8. API-compatible with URLSession.AsyncBytes for the surface callers actually use (.lines is an extension on AsyncSequence where Element == UInt8).
  • GatewayHTTPClient.StreamingBytesDelegate — private URLSessionDataDelegate. One-shot response continuation serialised with NSLock. setResponseContinuation checks responseResolved first and resumes immediately with a stashed completionError if the delegate already completed — closes the race between task.delegate = delegate and the delegate queue firing didCompleteWithError before safeBytes stores the continuation.
  • TCP backpressure. StreamingBytesDelegate tracks yielded-but-not-yet-consumed bytes; crossing a 1 MiB high-water mark calls URLSessionDataTask.suspend(), draining below a 256 KiB low-water mark calls resume(). Suspending stops reading from the socket, so the OS receive buffer fills and TCP flow control throttles the remote sender — matching the built-in buffering of URLSession.AsyncBytes. Both suspend() and resume() run while holding the delegate's NSLock, so the flag flip and task-state change are atomic; otherwise a fast consumer on another thread could clear suspendedForBackpressure and call resume() between the delegate's flag-set and suspend(), leaving the task suspended with the flag cleared and no code path to wake it up. Apple documents URLSessionTask.suspend() / resume() as thread-safe and they do not synchronously re-enter the delegate, so holding our lock across them is safe.
  • stream() and streamPostWithRetry() — return type changes from URLSession.AsyncBytes to SafeAsyncBytes. streamPostWithRetry gains a session: URLSession = .shared parameter so callers can opt into dedicated sessions without touching current call sites.
  • streamPost (non-retry variant) — deleted. Zero call sites since Move sendBtwMessage to BtwClient, add streamPost to GatewayHTTPClient #19442; BtwClient (the only POST-SSE caller) uses streamPostWithRetry.
  • EventStreamClient.startSSEStream — updated the block comment above the sseSession === session guard to note it's now a cheap MainActor-side early bailout, with GatewayHTTPClient.stream's ObjC trampoline as the authoritative defense.

Alternatives considered

  • Making stream() @MainActor — would serialise task creation with invalidation, but contradicts the deliberate non-isolation of the network layer (PR Move network layer off @MainActor isolation (LUM-492) #21729) and would hop every non-SSE caller through MainActor.
  • Probe-then-call — probe dataTask(with:) inside @try/@catch, cancel the probe, then call bytes(for:) normally. Leaves a TOCTOU window between a successful probe and the real call. Rejected in favour of owning the real call site.
  • Wrapping await session.bytes(for:) directly in @try/@catch — infeasible; ObjC exception handlers are synchronous and cannot wrap await.
  • AsyncThrowingStream's bounded bufferingOldest(N) / bufferingNewest(N) — both policies drop chunks on overflow, which would corrupt a byte-accurate stream. Rejected in favour of real TCP backpressure via task.suspend() / resume().
  • suspend() / resume() outside the lock — simpler but not atomic with the flag flip, which allowed the race flagged in review (clear-flag-then-resume interleaving with set-flag-then-suspend leaves the task suspended with the flag cleared). Rejected in favour of holding the lock across the task-state change.

Root cause analysis

  1. How did the code get into this state? PR fix: use dedicated URLSession per SSE connection to prevent EXC_BAD_ACCESS #25396 moved SSE to a dedicated URLSession but left URLSession.bytes(for:) as the byte-stream primitive; PR fix(macos): guard SSE task against superseded session on MainActor race #25426 added a MainActor-side sseSession === session guard assuming it was sufficient. Neither accounted for the fact that URLSession.bytes(for:) synchronously calls -[NSURLSession dataTaskWithRequest:] from its non-isolated async body, leaving a window between the MainActor guard and task creation where another MainActor turn can invalidate the session.
  2. What mistakes or decisions led to it? Assuming a Swift-level guard can protect a Foundation synchronous call that happens on a different actor / thread. The real lifecycle contract of URLSession is "do not create tasks after invalidateAndCancel()" and enforcing that requires owning the dataTask(with:) call site.
  3. Were there warning signs we missed? LUM-903's own description flagged that streamPost / streamPostWithRetry still used URLSession.shared — the same class of lifecycle bug in a sibling entry point. And the fact that Swift do/catch couldn't catch the crash in production was itself a signal that the exception was ObjC, not Swift.
  4. What can we do to prevent this pattern from recurring? Any time we call a Foundation async API that internally creates an NSURLSessionTask on a session whose lifecycle we don't own, wrap the synchronous prefix in the ObjC trampoline or drive the task ourselves. Guards on MainActor alone are not sufficient for non-isolated callees.
  5. AGENTS.md suggestion. Under clients/AGENTS.md → "Concurrency and Task Management", add a short durable rule: "Swift's do/catch cannot intercept Objective-C NSExceptions. When a Foundation API is documented (or empirically known) to raise NSException on misuse — e.g. -[NSURLSession dataTaskWithRequest:] on an invalidated session, or AVAudioNode.installTap(onBus:bufferSize:format:) on format mismatch — wrap the call site in VLMPerformWithObjCExceptionHandling so the exception surfaces as a Swift-catchable NSError." Kept short so it doesn't drift.

Self-verified review items

Items below are verified from the diff / codebase and do not need a second human pass.

  • Deployment targets meet per-task delegate requirement. clients/Package.swift declares .macOS("15.0") and .iOS(.v17); URLSessionTask.delegate requires iOS 15+ / macOS 12+.
  • All streaming call sites route through SafeAsyncBytes. rg '\.bytes\(for:' clients/**/*.swift returns zero matches. The three consumers (EventStreamClient.startSSEStream, BtwClient.streamBtw, TerminalAPIClient.subscribeEvents) all consume via bytes.lines, which resolves against AsyncSequence where Element == UInt8 — satisfied by SafeAsyncBytes.
  • No unguarded session.dataTask(with:) remains on the streaming path. Only one call site; it's inside the VLMPerformWithObjCExceptionHandling trampoline in safeBytes.
  • Cancellation coverage. (a) consumer teardown → chunksContinuation.onTermination → task.cancel(); (b) parent Swift task cancellation during header wait → withTaskCancellationHandler.onCancel → task.cancel(); (c) session invalidation → didCompleteWithErrorchunksContinuation.finish(throwing:). All three paths converge on the iterator's thrown error.
  • No retain cycles. StreamingBytesDelegate.task is weak var; bytesConsumed captures [weak self].
  • No continuation leak on late setup. setResponseContinuation resumes immediately if responseResolved == true, using the stashed completionError (or URLError(.cancelled) as a fallback).
  • Backpressure state synchronisation. Both dataTask.suspend() and task?.resume() execute while the delegate's NSLock is held; the flag and task-state change are atomic. Apple documents URLSessionTask.suspend() / resume() as thread-safe and they do not synchronously re-enter the delegate — safe to hold our own lock across them.
  • Byte counter never negative. pendingBytes += data.count happens before yield(data), and the decrement uses max(0, pendingBytes - count).
  • Dead-code removal of streamPost. rg 'streamPost\b' shows only streamPostWithRetry call sites remain (BtwClient is the sole caller; default session kept its signature binary-compatible for that caller).
  • Source compatibility of the return-type change. No call site uses AsyncBytes-only APIs (.task, delegate inspection); all three use .lines.

Remaining human-only checks

  • Clean compile on macOS and iOS targets. Authored on Linux without swift / xcodebuild; CI is the pre-merge signal but required jobs are skipped under this repo's Devin-authored-branch policy, so local Xcode is the authoritative check.
  • Profile TerminalAPIClient.subscribeEvents under load. The 1 MiB / 256 KiB thresholds are chosen conservatively; the suspend/resume cadence on a realistic high-volume terminal session is worth a quick Instruments pass to confirm no pathological flapping.
  • Validate reconnect cadence on persistent invalidation. If the session stays invalidated, safeBytes throws → handleSSEDisconnect()scheduleSSEReconnect() uses exponential backoff; worth eyeballing once locally to confirm it doesn't spin tight before the reconnect path replaces the session.

References

Test plan

  • Happy path: safeBytes drives the stream from delegate callbacks; callers use bytes.lines exactly as before.
  • Invalidated-session path: dataTask(with:) on an invalidated session throws URLError(.cancelled) into EventStreamClient.startSSEStream's existing do/catchhandleSSEDisconnect() → reconnect.
  • Consumer-side cancellation after headers: chunksContinuation.onTermination cancels the underlying URLSessionDataTask.
  • Parent-task cancellation before headers: withTaskCancellationHandler.onCancel cancels the data task; didCompleteWithError(.cancelled) fires; the response continuation throws.
  • Late-continuation race: setResponseContinuation resolves immediately against a stashed completionError if the delegate has already completed.
  • Backpressure: slow consumer on a high-volume stream → URLSessionDataTask suspended above 1 MiB queued, resumed below 256 KiB. Suspension + chunksContinuation.onTermination → task.cancel() still tears down cleanly (cancel() overrides suspend() per URLSessionTask docs).
  • The invalidation race is inherently non-deterministic and not reproducible in a unit test; production Sentry is the right post-ship signal.

Link to Devin session: https://app.devin.ai/sessions/356bfc5ee25d4c0e9d49eefc818de35d
Requested by: @ashleeradka


Open with Devin

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@linear

linear Bot commented Apr 18, 2026

Copy link
Copy Markdown

LUM-1001 Crash: NSGenericException — Task created in invalidated URLSession during SSE stream (MACOS-J4)

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 2 additional findings.

Open in Devin Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e51fdd6520

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

?? "URLSession was invalidated before the stream could start"
])
}
return try await session.bytes(for: request)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Catch invalidation around the real bytes(for:) call

safeBytes only wraps the probe dataTask(with:) in VLMPerformWithObjCExceptionHandling, but the actual session.bytes(for:) invocation is still outside that protection. In the SSE path, EventStreamClient can invalidate the same session on @MainActor while this nonisolated function is running, so invalidation that lands after a successful probe can still trigger NSGenericException and abort the process. This leaves the crash path reachable during rapid stop/start or reconnect races, so the fix is incomplete unless task creation for the real stream is also protected/serialized.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right that the probe-then-call left a TOCTOU window (the real bytes(for:) call was still outside the trampoline, and on-MainActor invalidation can interleave with on-concurrent-executor code on a different thread). Addressed in a3bbc46: replaced the probe approach with a full URLSessionDataDelegate-driven SafeAsyncBytes. safeBytes() now owns the dataTask(with:) call site directly, wraps it in VLMPerformWithObjCExceptionHandling, and drives the byte stream from per-task delegate callbacks — there's no URLSession.bytes(for:) call left to race against. Resolved.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

Clean fix for a real crash. The safeBytes probe approach is clever — reuses the existing VLMPerformWithObjCExceptionHandling ObjC trampoline to catch the NSGenericException before it becomes a SIGABRT, and the synchronous probe → cancel → real call sequence correctly avoids TOCTOU since session invalidation only happens on @MainActor and can't interleave with the non-await code on the concurrent executor.

Key things I verified:

  • The probe dataTask(with:) stays suspended (no .resume()), so zero network side effects
  • Backward compatible: session: URLSession = .shared default on streamPost/streamPostWithRetry
  • The EventStreamClient comment update correctly reflects that safeBytes is now the authoritative defense, with the sseSession === session guard remaining as a cheap early bailout
  • Error surfaces as URLError(.cancelled) which existing callers already handle

Devin Review: ✅ No issues found. Ready to merge.

@devin-ai-integration devin-ai-integration Bot changed the title fix(macos): catch NSGenericException from invalidated URLSession in SSE stream fix(macos): replace URLSession.bytes with a delegate-driven stream to survive session invalidation Apr 18, 2026
vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

Summary

This evolved well from the initial probe approach to a full delegate-based streaming implementation that eliminates bytes(for:) entirely rather than just guarding it. Much more robust.

What I Checked

  • SafeAsyncBytes API compatibility — conforms to AsyncSequence<UInt8>, so Foundation's .lines extension works transparently. All callers (EventStreamClient.startSSEStream, BtwClient, TerminalAPIClient) use type-inferred let (bytes, response) — no explicit URLSession.AsyncBytes type annotations to break. ✅
  • Thread safetyStreamingBytesDelegate serializes the response continuation under NSLock. Both didReceive response and failResponseIfPending nil out + resume under the lock, guaranteeing exactly-one-resume. chunksContinuation is inherently Sendable. ✅
  • Task failure before responsedidCompleteWithError checks responseResolved and resumes the pending continuation with the error. Edge case handled. ✅
  • onTermination cleanup — cancels the data task when the consumer stops iterating. ✅
  • streamPostWithRetry retry path — retry also goes through safeBytes with the same session parameter. Fresh connection credentials but same session lifecycle. Correct. ✅
  • ObjC trampoline reuseVLMPerformWithObjCExceptionHandling already battle-tested for AVAudioNode.installTap. Using it for dataTask(with:) and resume() is the same pattern. ✅
  • No behavior change in happy path — same bytes, same response, same error paths. Just driven by delegate instead of Foundation's internal async wrapper.

One note for local build verification

The per-task delegate (task.delegate = delegate) requires iOS 15+ / macOS 12+ — should be fine given our deployment targets, but worth confirming in the Xcode build.

Verdict

Clean, well-documented, correct fix for a real SIGABRT crash. The delegate-based approach is architecturally better than probing because it removes the entire bytes(for:) call surface rather than wrapping it. Ship it.

devin-ai-integration[bot]

This comment was marked as resolved.

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

v3 Review

This is a solid, complete replacement for URLSession.bytes(for:). The evolution from v1 (probe) → v2 (delegate-driven) → v3 (continuation leak fix) landed in a good place.

Key strengths:

  • SafeAsyncBytes is source-compatible with URLSession.AsyncBytes for the .lines usage — no caller changes needed
  • StreamingBytesDelegate.setResponseContinuation properly handles the race where didCompleteWithError fires before the continuation is stored (responseResolved + completionError stash). This was the real v3 fix and it's correct.
  • NSLock serialization is appropriate — URLSession delegate callbacks come from a session-owned background queue, but the setResponseContinuation call comes from the caller's context. The lock covers both paths.
  • onTermination cleanup ensures the data task is cancelled if the consumer stops iterating
  • ObjC trampoline on both dataTask(with:) and resume() is belt-and-suspenders — good defensive coding

Merge readiness: CI checks are skipped (Devin branch policy). Needs local Xcode build verification before merge. Two files changed, well-scoped. No behavioral change in the happy path — only the crash-on-invalidation path is affected.

ashleeradka
ashleeradka previously approved these changes Apr 18, 2026

@ashleeradka ashleeradka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ✅ (v3 — f8055bc)

The delegate-driven SafeAsyncBytes is the right architecture here. Key things I verified across all 3 iterations:

  1. v3 continuation leak fixsetResponseContinuation now checks responseResolved first and resumes immediately if the delegate already fired didCompleteWithError. This closes the real race between task.delegate = delegate and the delegate queue. completionError stash ensures the actual error surfaces rather than a generic .cancelled.

  2. NSLock serialization — All responseContinuation / responseResolved / completionError mutations are lock-guarded. The lock-unlock-then-resume pattern avoids holding the lock across continuation resume (which could deadlock if the continuation scheduled work on the same queue).

  3. chunksContinuation.onTermination — Task gets cancelled when consumer stops iterating. Clean teardown.

  4. ObjC trampoline on both dataTask(with:) and resume() — Belt and suspenders. Even if resume() somehow throws on an invalidated session, it's caught.

  5. streamPost and streamPostWithRetry gain session: parameter — Consistency with stream(). No caller changes needed since it defaults to .shared.

Needs local Xcode build verification before merge — CI checks skipped on Devin branches.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ✅ (v3 — f8055bc)

The delegate-driven SafeAsyncBytes is the right architecture. Verified across all 3 iterations:

  1. v3 continuation leak fixsetResponseContinuation checks responseResolved first and resumes immediately if the delegate already fired didCompleteWithError. completionError stash ensures the actual error surfaces rather than a generic .cancelled.

  2. NSLock serialization — All responseContinuation / responseResolved / completionError mutations are lock-guarded. Lock-unlock-then-resume pattern avoids deadlock.

  3. chunksContinuation.onTermination — Task gets cancelled when consumer stops iterating. Clean teardown.

  4. ObjC trampoline on both dataTask(with:) and resume() — Belt and suspenders.

  5. streamPost and streamPostWithRetry gain session: parameter — Consistency with stream(). No caller changes needed.

Needs local Xcode build verification before merge — CI checks skipped on Devin branches.

devin-ai-integration[bot]

This comment was marked as resolved.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving on v4 (3940c8d). The delta from v3 is a pure deletion of the unused streamPost non-retry entry point (0 additions, 26 deletions) — dead code since #19442. The v3 delegate-based SafeAsyncBytes + continuation leak fix remains intact and correct. ✅

Still needs local Xcode build verification before merge (CI checks skipped on Devin branches).

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@devin-ai review this PR

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3940c8d30b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +575 to +579
let response: URLResponse = try await withCheckedThrowingContinuation { continuation in
delegate.setResponseContinuation(continuation)
var resumeError: NSError?
let resumed = VLMPerformWithObjCExceptionHandling({
task.resume()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate task cancellation while waiting for first response

safeBytes suspends in withCheckedThrowingContinuation until delegate callbacks arrive, but there is no withTaskCancellationHandler to cancel the underlying URLSessionDataTask if the parent Swift task is cancelled before headers are received. In current callers (for example BtwClient.sendMessage cancelling its worker task on stream termination), this means a cancelled operation can keep the HTTP request alive until timeout/remote completion instead of stopping promptly, which can leave stale network work and delay teardown.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d1b4111.

Wrapped the response-header withCheckedThrowingContinuation in withTaskCancellationHandler, so a parent Swift task cancellation (e.g. BtwClient.sendMessage tearing down its worker) that lands between task.resume() and the first delegate callback now calls task.cancel() on the underlying URLSessionDataTask. Cancelling fires didCompleteWithError(URLError.cancelled), which the delegate converts into a thrown URLError on the pending continuation — matching the cancellation semantics of URLSession.bytes(for:).

chunksContinuation.onTermination still handles the post-headers case (consumer stops iterating), so cancellation is covered both before and after the initial response.

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ✅ (re-review on v5 d1b4111)

Reviewed the full diff across all 5 commits. The evolution v1→v5 is clean:

  1. v1-v2: Probe approach → full delegate-based SafeAsyncBytes replacing URLSession.bytes(for:) entirely
  2. v3: Continuation leak fix — setResponseContinuation checks responseResolved first, stashes completionError for the late-continuation race
  3. v4: Dead code removal — unused streamPost non-retry entry point (pure deletion)
  4. v5: withTaskCancellationHandler wrapping the header-wait continuation — fixes the Codex-found P1 where parent task cancellation before headers arrived would leave the URLSessionDataTask alive until server timeout. onCancel calls task.cancel(), firing didCompleteWithError(.cancelled), which the delegate translates to a thrown URLError on the pending continuation. Matches URLSession.bytes(for:) cancellation semantics.

Concurrency Deep-Check (R11b)

  • Race enumeration: NSLock serializes the one-shot response continuation. responseResolved + completionError stash handle the delegate-fires-before-continuation race. withTaskCancellationHandler handles parent cancellation during header wait. ✅
  • Continuation lifecycle: Single response continuation, set once via setResponseContinuation, consumed exactly once (either by didReceive response or by failResponseIfPending/didCompleteWithError/late-set path). No double-resume possible. ✅
  • Cancellation propagation: Three paths covered — (a) consumer stops iterating → onTermination cancels task, (b) parent task cancelled before headers → withTaskCancellationHandler cancels task, (c) session invalidated → ObjC trampoline catches and throws. ✅
  • Actor isolation: Delegate callbacks on URLSession background queue, properly serialized with NSLock. No MainActor assumptions. ✅

EventStreamClient changes

Comment update only — accurately describes the guard as a cheap early bailout with safeBytes as the authoritative defense. ✅

Still blocked on local Xcode build verification (Devin authored on Linux). The code is correct and ready for that final step.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ✅ (v5 — d1b4111)

v5 delta from v4: wraps the header-wait withCheckedThrowingContinuation in withTaskCancellationHandler. When the parent Swift task is cancelled before headers arrive, onCancel calls task.cancel() on the underlying URLSessionDataTask, which fires didCompleteWithError(.cancelled) through the delegate and resumes the continuation cleanly.

This fixes the P1 Codex identified: without it, a cancelled parent task would leave the HTTP request running until server timeout. The cancellation semantics match URLSession.bytes(for:)'s built-in behavior.

All 5 commits reviewed across the full evolution: v1 probe → v2 delegate-based SafeAsyncBytes → v3 continuation leak fix → v4 dead code cleanup → v5 task cancellation handler. Solid work.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@devin-ai review this PR

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1b4111a44

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

/// `AsyncSequence` with `Element == UInt8`, so callers can use `bytes.lines`
/// exactly like `URLSession.AsyncBytes`.
private static func safeBytes(session: URLSession, request: URLRequest) async throws -> (SafeAsyncBytes, URLResponse) {
let (chunks, chunksContinuation) = AsyncThrowingStream<Data, Error>.makeStream()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound SafeAsyncBytes buffering to prevent OOM

safeBytes creates the chunk stream with AsyncThrowingStream<Data, Error>.makeStream() and later yields every delegate chunk into it, which defaults to unbounded buffering. If the producer outpaces consumers (for example, high-volume terminal/SSE output while parsing runs on @MainActor), queued Data chunks can grow without limit and cause significant memory growth or process termination. This is a regression from the previous URLSession.AsyncBytes path and should use an explicit bounded buffering/backpressure strategy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in f6bf772.

StreamingBytesDelegate now tracks yielded-but-not-yet-consumed bytes and drives URLSessionDataTask.suspend() / .resume() around a 1 MiB high-water / 256 KiB low-water band. Suspending the data task stops reading from the socket, so the OS receive buffer fills and TCP flow control throttles the remote sender — matching URLSession.AsyncBytes's built-in backpressure.

Implementation notes:

  • SafeAsyncBytes.AsyncIterator gets a @Sendable (Int) -> Void bytesConsumed callback (exposed as a lazy var on the delegate so the iterator doesn't need to import the delegate type) and invokes it each time it drains a chunk from the underlying AsyncThrowingStream.
  • The delegate increments pendingBytes before chunksContinuation.yield(data) so a fast consumer on another thread can't race the delegate and push the counter negative.
  • suspend() / resume() are called outside the lock; the weak task reference lets the delegate drop out of scope cleanly when the caller's stream ends.
  • onTermination → task.cancel() still wins over a backpressure-suspend, since cancel() overrides suspend() per URLSessionTask docs.

Resolved.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ✅ (re-review on v6 f6bf772)

The v6 delta from v5 is a single commit: TCP backpressure for SafeAsyncBytes.

What v6 adds

AsyncThrowingStream.makeStream() defaults to unbounded buffering, so when the delegate-driven stream produces faster than the consumer drains (e.g. high-volume terminal SSE), memory grows without limit. v6 adds in-flight byte accounting:

  • High-water mark (1 MiB): URLSessionDataTask is suspended when yielded-but-unconsumed bytes cross this threshold
  • Low-water mark (256 KiB): task resumes once consumer drains below — the gap prevents suspend/resume thrashing
  • pendingBytes incremented before yield() to prevent a race where a fast consumer on another thread drains before accounting
  • weak var task avoids retain cycles; bytesConsumed closure uses [weak self]
  • All state guarded by NSLock

This restores the flow control semantics that URLSession.AsyncBytes provided natively. Clean design, thorough documentation in the commit message and inline comments.

No concerns. The full PR across all 6 commits is solid — delegate-based stream (v1-v3) → dead code cleanup (v4) → task cancellation handler (v5) → backpressure (v6). Each layer builds correctly on the last.

Still needs: Boss re-approval on v6 + local Xcode build verification.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@devin-ai review this PR — v6 added TCP backpressure to the SafeAsyncBytes chunk stream. Please review the new high/low water-mark flow control mechanism.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@codex review — v6 commit f6bf772 adds TCP backpressure (high/low water-mark) to SafeAsyncBytes. Please review the thread safety and flow control logic.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f6bf7728bb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +722 to +726
if shouldResume {
self.suspendedForBackpressure = false
}
let task = self.task
self.lock.unlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep backpressure state synchronized with task suspension

This closure clears suspendedForBackpressure before resume() is actually executed, while didReceive data performs suspend() later (after yield) on a different thread. If the consumer drains to the low-water mark in that window, bytesConsumed can flip the flag to false and call resume() while the task is still running; then didReceive still executes suspend(), leaving the task suspended with suspendedForBackpressure == false, so no later path resumes it and the stream can stall indefinitely under high-throughput boundary conditions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real race — fixed in ed0eb6e5 (v7).

Both dataTask.suspend() and task.resume() now run inside the delegate's NSLock, so the flag flip and the task-state change are atomic with each other. The interleave you described is no longer possible:

  • didReceive data: holds the lock across pendingBytes += …suspendedForBackpressure = truedataTask.suspend(), then unlocks.
  • bytesConsumed holds the lock across pendingBytes -= …suspendedForBackpressure = falsetask.resume(), then unlocks.

URLSession's suspend() / resume() are documented as thread-safe and don't synchronously call back into the delegate, so holding our own lock across them is safe — no risk of deadlock from URLSession invoking another delegate method on the same queue.

yield(data) stays outside the lock since it's a pure buffer write that doesn't need serialisation with the backpressure accounting. Resolved.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved ✅ (re-review on v7 ed0eb6e5)

v7 delta from v6: moves task.suspend() and task.resume() inside the delegate lock. This fixes a real race Codex caught in v6:

The race (v6)

  1. didReceive data: sets suspendedForBackpressure = true and calls suspend() — but the flag set and suspend were not atomic
  2. A fast consumer on another thread could call bytesConsumed, see the flag, clear it, and call resume() between step 1's flag set and suspend call
  3. Result: task suspended with flag cleared → no code path to wake it up → stream deadlock

The fix (v7)

Both suspend() (in didReceive data:) and resume() (in bytesConsumed) now execute while holding lock, making the flag-flip and task-state-change atomic. The documentation in the code comments is excellent — explains exactly what race this prevents.

Overall: 7-commit evolution from probe → delegate → continuation leak fix → dead code cleanup → cancellation propagation → TCP backpressure → lock serialization. Each commit is well-scoped and well-documented. Ship it.

Still needs: local Xcode build verification + Boss re-approval.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@devin-ai review this PR — v7 serializes backpressure suspend/resume under the delegate lock to fix a race condition Codex found in v6.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@codex review — v7 commit ed0eb6e moves task.suspend()/resume() inside the NSLock to prevent a race between didReceive and bytesConsumed.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…SE stream

URLSession.bytes(for:) internally calls -[NSURLSession dataTaskWithRequest:]
from the synchronous prefix of its async body. If the session is invalidated
on another thread — even during that call — the method raises an Objective-C
NSGenericException ("Task created in a session that has been invalidated")
from -[__NSURLSessionLocal taskForClassInfo:]. Swift do/catch cannot
intercept ObjC exceptions, so the process aborts with SIGABRT.

Replace URLSession.bytes(for:) with a delegate-driven byte stream that
owns task creation. The data task is constructed inside the existing
VLMPerformWithObjCExceptionHandling ObjC trampoline, so an invalidated
session surfaces a Swift URLError(.cancelled) instead of aborting. A
per-task URLSessionDataDelegate (iOS 15+ / macOS 12+) drives chunk
delivery into an AsyncThrowingStream, exposed as SafeAsyncBytes which
conforms to AsyncSequence so callers use .lines exactly like
URLSession.AsyncBytes.

The delegate covers the full stream lifecycle:
- didReceive response resolves the initial response continuation;
  responseResolved + completionError let a late setResponseContinuation
  call resume immediately with the real error if the delegate already
  completed (e.g. session invalidated between task.delegate = … and
  the caller getting back on-thread).
- didCompleteWithError translates transport errors into a thrown
  URLError on the chunk stream, including explicit cancellation.
- withTaskCancellationHandler propagates parent Swift task cancellation
  to URLSessionDataTask.cancel(), matching URLSession.bytes(for:)'s
  built-in cancellation semantics so callers cancelling before headers
  arrive don't leave the request running until server timeout.
- TCP backpressure: in-flight byte accounting suspends the data task
  at a 1 MiB high-water mark and resumes it below a 256 KiB low-water
  mark. Suspending stops reading from the socket, which lets the OS
  receive buffer fill and throttle the server via TCP flow control,
  preserving the flow control that URLSession.AsyncBytes has natively.
  Both suspend() and resume() are invoked while the delegate's NSLock
  is held so the flag flip and task-state change are atomic.

Also adds the session: URLSession = .shared parameter to
streamPostWithRetry so future callers can pass a dedicated session
with proper lifecycle, matching stream(). Removes the unused
streamPost entry point (dead since #19442).

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/lum-1001-1776475015-sse-session-nsgeneric-exception branch from ed0eb6e to d5d07a7 Compare April 18, 2026 14:40
@devin-ai-integration devin-ai-integration Bot changed the title fix(macos): replace URLSession.bytes with a delegate-driven stream to survive session invalidation fix(macos): replace URLSession.bytes with delegate-driven stream to survive session invalidation Apr 18, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approved ✅ (post-squash d5d07a78)

Squashed 7 commits into one clean commit. Diff is identical to the v7 I approved earlier. SafeAsyncBytes delegate-driven stream, ObjC exception trampoline, lock-serialized backpressure. No code changes.

⚠️ CI checks still skipped — needs local Xcode build verification before merge.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@devin-ai review this PR

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@ashleeradka ashleeradka deleted the devin/lum-1001-1776475015-sse-session-nsgeneric-exception branch April 18, 2026 15:08
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