Move network layer off @MainActor isolation#21695
Conversation
Move all network client protocols, structs, and the GatewayHTTPClient enum off @mainactor isolation. Per Apple best practices (WWDC25), @mainactor should only be used for UI state — not for networking, URL construction, or I/O operations. This eliminates the root cause of app hangs (LUM-457) where URLRequest construction, URL resolution, and lockfile reads were forced onto the main thread by @mainactor annotations on the entire network layer. Changes: - Remove @mainactor from GatewayHTTPClient enum - Remove @mainactor from HealthCheckClient enum - Remove @mainactor from ~40 network client protocols and structs - Remove @mainactor from HostToolExecutor static methods - Remove unnecessary @mainactor Task in BtwClient.sendMessage() Kept @mainactor on: - EventStreamClient (stateful class managing SSE subscribers) - GatewayConnectionManager (ObservableObject driving UI) No logic or behavior changes — only annotation removals. All callers already use await, which handles actor-hopping correctly. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
⚙️ Control Options:
|
LUM-492 Move network layer off @MainActor isolation
ContextThe entire network layer ( ProblemApple's best practice is to keep networking code off the main thread. Scope
Acceptance Criteria
Summary: Remove inappropriate Key Context:
✨ Generated by Linear Issue Context Agent |
…ctor-isolated AuthService.baseURL Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
This reverts commit dd23f7a.
This reverts commit dd23f7a. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Remove @mainactor isolation from GatewayHTTPClient, HealthCheckClient, and all ~40 stateless network client protocols/structs. This moves HTTP request construction, URL resolution, and synchronous file I/O off the main thread onto the cooperative thread pool. Unlike the previous attempt (#21695), resolveConnection() stays synchronous. The original PR made it async to safely read AuthService.shared.baseURL via await, but the resulting actor-hopping (MainActor → cooperative pool → MainActor → cooperative pool) during the startup health-check path caused the app to hang immediately on launch. The fix: use if/else instead of the ?? operator for the AuthService fallback. The ?? operator wraps its right operand in an autoclosure, which is an error for @MainActor-isolated property access. Direct access in a nonisolated function body is a Swift 5 warning (safe at runtime since String is a value type and the underlying reads are thread-safe). What changes: - @mainactor removed from 43 network client files - resolveConnection() remains synchronous (no async, no actor-hopping) - isConnectionManaged(), buildURL() remain synchronous - handleAuthenticationFailure() remains synchronous - BtwClient Task keeps @mainactor annotation - EventStreamClient and GatewayConnectionManager retain @mainactor Refs: LUM-492 Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
* Remove @mainactor from network layer, keep resolveConnection synchronous Remove @mainactor isolation from GatewayHTTPClient, HealthCheckClient, and all ~40 stateless network client protocols/structs. This moves HTTP request construction, URL resolution, and synchronous file I/O off the main thread onto the cooperative thread pool. Unlike the previous attempt (#21695), resolveConnection() stays synchronous. The original PR made it async to safely read AuthService.shared.baseURL via await, but the resulting actor-hopping (MainActor → cooperative pool → MainActor → cooperative pool) during the startup health-check path caused the app to hang immediately on launch. The fix: use if/else instead of the ?? operator for the AuthService fallback. The ?? operator wraps its right operand in an autoclosure, which is an error for @MainActor-isolated property access. Direct access in a nonisolated function body is a Swift 5 warning (safe at runtime since String is a value type and the underlying reads are thread-safe). What changes: - @mainactor removed from 43 network client files - resolveConnection() remains synchronous (no async, no actor-hopping) - isConnectionManaged(), buildURL() remain synchronous - handleAuthenticationFailure() remains synchronous - BtwClient Task keeps @mainactor annotation - EventStreamClient and GatewayConnectionManager retain @mainactor Refs: LUM-492 Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> * fix: make AuthService.configuredBaseURL thread-safe for nonisolated access Move configuredBaseURL storage to a module-level lock-protected variable so GatewayHTTPClient.resolveConnection() can read it without crossing into @mainactor isolation. - Add NSLock-protected module-level storage for configuredBaseURL - Add nonisolated AuthService.currentConfiguredBaseURL static accessor - Mark resolveBaseURL/normalizedBaseURL as nonisolated (pure functions) - Replace AuthService.shared.baseURL with direct call to nonisolated AuthService.resolveBaseURL in GatewayHTTPClient.resolveConnection() Fixes Swift 6 compile error: main actor-isolated property 'baseURL' cannot be referenced from a nonisolated context. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ashlee@vellum.ai <ashlee@vellum.ai>
The 15-second health check loop previously ran as a Task { @mainactor ... }
so the while-loop, Task.sleep scheduling, and error handling all occupied
the main actor. Under memory pressure this periodic work contributes to
app hangs (LUM-914 recorded a ≥2000ms hang attributed to this loop on a
device with ~131MB free RAM).
Change the loop to a detached task at .utility priority and hop to
@mainactor only for the state reads (isUpdateInProgress,
healthCheckInterval, shouldReconnect) and the update-timeout cleanup
that mutates @published properties. performHealthCheck() itself stays
@mainactor so all @published writes and cached-assistant reads remain on
main — no new data races, no changes to resolveConnection() or the rest
of the network stack that triggered the earlier revert of #21695.
Refs: WWDC25 Embracing Swift concurrency
(https://developer.apple.com/videos/play/wwdc2025/268/),
Task.detached(priority:), MainActor.run.
Closes LUM-916
Co-Authored-By: tkheyfets <timur@vellum.ai>
The 15-second health check loop previously ran as a Task { @mainactor ... }
so the while-loop, Task.sleep scheduling, and error handling all occupied
the main actor. Under memory pressure this periodic work contributes to
app hangs (LUM-914 recorded a ≥2000ms hang attributed to this loop on a
device with ~131MB free RAM).
Change the loop to a detached task at .utility priority and hop to
@mainactor only for the state reads (isUpdateInProgress,
healthCheckInterval, shouldReconnect) and the update-timeout cleanup
that mutates @published properties. performHealthCheck() itself stays
@mainactor so all @published writes and cached-assistant reads remain on
main — no new data races, no changes to resolveConnection() or the rest
of the network stack that triggered the earlier revert of #21695.
Refs: WWDC25 Embracing Swift concurrency
(https://developer.apple.com/videos/play/wwdc2025/268/),
Task.detached(priority:), MainActor.run.
Closes LUM-916
Co-Authored-By: tkheyfets <timur@vellum.ai>
…ctor (#26082) * Run GatewayConnectionManager health check loop off @mainactor The 15-second health check loop previously ran as a Task { @mainactor ... } so the while-loop, Task.sleep scheduling, and error handling all occupied the main actor. Under memory pressure this periodic work contributes to app hangs (LUM-914 recorded a ≥2000ms hang attributed to this loop on a device with ~131MB free RAM). Change the loop to a detached task at .utility priority and hop to @mainactor only for the state reads (isUpdateInProgress, healthCheckInterval, shouldReconnect) and the update-timeout cleanup that mutates @published properties. performHealthCheck() itself stays @mainactor so all @published writes and cached-assistant reads remain on main — no new data races, no changes to resolveConnection() or the rest of the network stack that triggered the earlier revert of #21695. Refs: WWDC25 Embracing Swift concurrency (https://developer.apple.com/videos/play/wwdc2025/268/), Task.detached(priority:), MainActor.run. Closes LUM-916 Co-Authored-By: tkheyfets <timur@vellum.ai> * Update comments to reflect @observable migration from #25496 Co-Authored-By: tkheyfets <timur@vellum.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: tkheyfets <timur@vellum.ai>
Summary
Removes
@MainActorfrom the network client layer (~44 files inclients/shared/Network/).@MainActorshould only isolate UI state — not networking, URL construction, or I/O. Previously, every HTTP request (including the 15-second health check) forcedURLRequestconstruction, URL resolution, and synchronous lockfile reads onto the main thread, contributing to app hangs (LUM-457).Why this is needed:
@MainActoris reserved for UI mutationsAppHangstacktrace (URLRequest.init→_SwiftURL._makeCFURL)What changed:
@MainActorfromGatewayHTTPClient,HealthCheckClient,HostToolExecutorstatic methods, and ~40 network client protocols + structsTask { @MainActor in→Task {inBtwClient.sendMessage()so streaming runs on the cooperative thread poolresolveConnection()asyncso it can safelyawaitthe@MainActor-isolatedAuthService.shared.baseURLvia actor hop — this cascadesasynctoisConnectionManaged(),buildURL(),handleAuthenticationFailure(), andInteractionClient.approvalPath()(all callers were already async)What stays
@MainActor:EventStreamClient— stateful SSE subscriber managementGatewayConnectionManager—ObservableObjectdriving UI stateAuthService— singleton whosebaseURL/configuredBaseURLis mutated from UI/settings codeWhy this is safe:
URLSession,UserDefaults, and Keychain APIs are thread-safeawait, so actor-hopping works transparently@MainActor-isolated read (AuthService.shared.baseURL) is now properlyawaited behind anif/elseto avoid Swift's??autoclosure limitationReview & Testing Checklist for Human
handleAuthenticationFailure()becomingasyncis safe — Previously synchronous (returned immediately, spawned internalTask). Nowawaited fromperformHealthCheck(). The managed-mode early-return path (broadcast error + disconnect) is synchronous, but confirm the health check flow still behaves correctly on 401s.isConnectionManaged()orbuildURL()were missed — All external callers found via grep were updated, but verify no other call sites exist in platform-specific targets.resolveConnection()now actor-hops forbaseURL.Notes
DispatchQueue.main.sync) remain for separate PRs.Link to Devin session: https://app.devin.ai/sessions/e19a9c46f61546368efaba6020397e17
Requested by: @ashleeradka