ios: better network handling

This commit is contained in:
2026-05-03 16:42:49 -07:00
parent 3820007289
commit e02168854c
5 changed files with 292 additions and 24 deletions

View File

@@ -72,19 +72,22 @@ public struct SplitView: View {
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshVisibleContent(
await viewModel.refreshAfterAppBecameActive(
refreshCollections: true,
refreshSelection: viewModel.hasRefreshableSelection
)
}
case .inactive:
break
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}

View File

@@ -85,7 +85,7 @@ extension Theme {
.paragraph { configuration in
configuration.label
.fixedSize(horizontal: false, vertical: true)
.relativeLineSpacing(.em(0.36))
.relativeLineSpacing(.em(0.46))
.markdownMargin(top: .zero, bottom: .em(0.82))
}
.blockquote { configuration in

View File

@@ -51,19 +51,22 @@ struct SybilPhoneShellView: View {
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground else {
return
}
shouldRefreshOnForeground = false
Task {
await viewModel.refreshVisibleContent(
await viewModel.refreshAfterAppBecameActive(
refreshCollections: path.isEmpty,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
)
}
case .inactive:
break
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
@unknown default:
break
}

View File

@@ -104,6 +104,10 @@ final class SybilViewModel {
@ObservationIgnored
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
@ObservationIgnored
private var isAppActive = true
@ObservationIgnored
private var appLifecycleGeneration = 0
@ObservationIgnored
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
private let fallbackModels: [Provider: [String]] = [
@@ -502,6 +506,38 @@ final class SybilViewModel {
await reconnect()
}
func markAppInactiveForNetwork() {
guard isAppActive else {
return
}
isAppActive = false
appLifecycleGeneration += 1
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
}
func markAppActiveForNetwork() {
isAppActive = true
}
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
markAppActiveForNetwork()
guard isAuthenticated, !isCheckingSession else {
return
}
guard shouldRefreshCollections || shouldRefreshSelection else {
return
}
try? await Task.sleep(for: .milliseconds(150))
await refreshVisibleContent(
refreshCollections: shouldRefreshCollections,
refreshSelection: shouldRefreshSelection
)
}
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
guard isAuthenticated, !isCheckingSession else {
return
@@ -729,6 +765,7 @@ final class SybilViewModel {
SybilLog.app,
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
)
errorMessage = nil
if case .settings = selectedItem {
isLoadingCollections = false
@@ -749,8 +786,12 @@ final class SybilViewModel {
await refreshSelectionIfNeeded()
}
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
} else {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
}
}
isLoadingCollections = false
@@ -789,9 +830,12 @@ final class SybilViewModel {
case .settings:
break
}
errorMessage = nil
} catch {
if isCancellation(error) {
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
} else if shouldSuppressInactiveTransportError(error) {
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
} else {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
@@ -882,6 +926,8 @@ final class SybilViewModel {
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
let streamStatus = CompletionStreamStatus()
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
if isUntitledChat(chatID: chatID, detail: selectedChat) {
Task { [weak self] in
@@ -917,24 +963,63 @@ final class SybilViewModel {
chatBackgroundTask = nil
}
try await client.runCompletionStream(
body: CompletionStreamRequest(
chatId: chatID,
provider: provider,
model: selectedModel,
messages: requestMessages
)
) { [weak self] event in
guard let self else { return }
await self.applyCompletionEvent(event, streamStatus: streamStatus)
do {
try await client.runCompletionStream(
body: CompletionStreamRequest(
chatId: chatID,
provider: provider,
model: selectedModel,
messages: requestMessages
)
) { [weak self] event in
guard let self else { return }
await self.applyCompletionEvent(event, streamStatus: streamStatus)
}
} catch {
if shouldSuppressLifecycleTransportError(
error,
startedAt: streamLifecycleGeneration,
startedWhileInactive: streamStartedWhileInactive
) {
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
pendingChatState = nil
if isAppActive {
await refreshInterruptedStream(preferredSelection: .chat(chatID))
}
return
}
throw error
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
guard isAppActive else {
pendingChatState = nil
return
}
await refreshCollections(preferredSelection: .chat(chatID))
selectedChat = try await client.getChat(chatID: chatID)
do {
selectedChat = try await client.getChat(chatID: chatID)
} catch {
if shouldSuppressLifecycleTransportError(
error,
startedAt: streamLifecycleGeneration,
startedWhileInactive: streamStartedWhileInactive
) {
SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
pendingChatState = nil
if isAppActive {
await refreshInterruptedStream(preferredSelection: .chat(chatID))
}
return
}
throw error
}
pendingChatState = nil
}
@@ -1003,19 +1088,41 @@ final class SybilViewModel {
)
let streamStatus = SearchStreamStatus()
let streamLifecycleGeneration = appLifecycleGeneration
let streamStartedWhileInactive = !isAppActive
try await client.runSearchStream(
searchID: searchID,
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
) { [weak self] event in
guard let self else { return }
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
do {
try await client.runSearchStream(
searchID: searchID,
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
) { [weak self] event in
guard let self else { return }
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
}
} catch {
if shouldSuppressLifecycleTransportError(
error,
startedAt: streamLifecycleGeneration,
startedWhileInactive: streamStartedWhileInactive
) {
SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
if isAppActive {
await refreshInterruptedStream(preferredSelection: .search(searchID))
}
return
}
throw error
}
if let streamError = await streamStatus.error() {
throw APIError.httpError(statusCode: 502, message: streamError)
}
guard isAppActive else {
return
}
await refreshCollections(preferredSelection: .search(searchID))
}
@@ -1250,6 +1357,57 @@ final class SybilViewModel {
#endif
}
private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
try? await Task.sleep(for: .milliseconds(150))
await refreshCollections(preferredSelection: preferredSelection)
}
private func shouldSuppressLifecycleTransportError(
_ error: Error,
startedAt generation: Int,
startedWhileInactive: Bool
) -> Bool {
guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
return false
}
return isTransientTransportInterruption(error)
}
private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
!isAppActive && isTransientTransportInterruption(error)
}
private func isTransientTransportInterruption(_ error: Error) -> Bool {
if isCancellation(error) {
return true
}
if let apiError = error as? APIError,
case let .networkError(message) = apiError {
let lowercased = message.lowercased()
return lowercased.contains("network error -999")
|| lowercased.contains("network error -1005")
|| lowercased.contains("network connection was lost")
|| lowercased.contains("software caused connection abort")
|| lowercased.contains("socket is not connected")
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
return nsError.code == URLError.cancelled.rawValue
|| nsError.code == URLError.networkConnectionLost.rawValue
|| nsError.code == URLError.notConnectedToInternet.rawValue
|| nsError.code == URLError.timedOut.rawValue
}
if nsError.domain == NSPOSIXErrorDomain {
return nsError.code == 53 || nsError.code == 57
}
return false
}
private func isCancellation(_ error: Error) -> Bool {
if error is CancellationError {
return true