diff --git a/Packages/OsaurusCore/AppDelegate.swift b/Packages/OsaurusCore/AppDelegate.swift index f7ac3c8bfd..80865269a8 100644 --- a/Packages/OsaurusCore/AppDelegate.swift +++ b/Packages/OsaurusCore/AppDelegate.swift @@ -743,12 +743,27 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega // Skip speculative warming in that state and let the first // real open pay its own (unavoidable) cost instead. guard !Self.isUnderResourcePressure else { return } + // Highlightr's first touch evaluates highlight.js in a + // JSContext; warm it on a background thread now so the + // first code block a chat cell renders doesn't pay the + // engine boot on main. + prewarmHighlightrOffMain() + // Same for the agent-secret account memo: the chat-preview + // compose reads it synchronously, and headless composers + // (HTTP, subagents, channels) never run the ChatView + // prewarm, so seed it here for the whole process. + AgentSecretsKeychain.prewarmAccounts() self?.prewarmManagementWindow() // Warm ChatView's (deep, slow-to-realize) generic metadata too, // spaced out so the two heavy SwiftUI realizations don't stack // into a single main-thread stall during the launch settle. try? await Task.sleep(for: .seconds(1.0)) guard !Self.isUnderResourcePressure else { return } + // Warm the sessions manager's first read through the + // database queue off the main actor before the ChatView + // prewarm makes it the first toucher of + // `ChatSessionsManager.shared` on main. + await ChatSessionsManager.prewarmShared() ChatWindowManager.shared.prewarmChatView() // And the menu-bar popover content, so the first click on // the status item doesn't pay the panel's first realization. @@ -1489,33 +1504,50 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega BrowserSessionManager.shared.shutdownAll() SharedConfigurationService.shared.remove() SharedConfigurationService.shared.flushPendingWork() - // Tool enable/policy changes persist via a background serial writer to - // keep the UI snappy; drain it here so a toggle made right before quit - // isn't lost when `_exit` skips the pending write. - ToolConfigurationStore.flushPendingWrites() - - // Same for the Computer Use autonomy policy (its own coalescing writer). - ComputerUsePolicyStore.flushPendingWrites() - - // Same for the sandbox and agent-delegation stores. - SandboxConfigurationStore.flushPendingWrites() - SubagentConfigurationStore.flushPendingWrites() - - // Provider/tool configuration files (remote.json, mcp.json, …) persist - // through ConfigDiskWriter's background queue, and credentials persist - // through the Keychain serial write queue. Drain both, bounded, so a - // provider added or edited right before quit survives relaunch — - // otherwise `_exit` below drops the pending write and the provider - // comes back disabled or credential-less. - ConfigDiskWriter.flushPendingWrites() - Keychain.flushPendingWrites() - - // Aptabase batches analytics in an in-memory queue and normally drains - // it from its own `willTerminate` observer — but that flush is async and - // the `_exit(0)` below skips it. Kick a final bounded, best-effort send - // so the last session's events have a chance to leave first. No-op unless - // telemetry is live and consented, so most quits pay nothing here. - TelemetryService.shared.flushForQuit() + // Drain the background writers so edits made right before quit aren't + // lost when `_exit` skips their pending writes: the coalescing config + // stores (tools / Computer Use policy / sandbox / delegation), the + // provider/tool files behind ConfigDiskWriter, and the Keychain serial + // write queue. Each drain is individually bounded, but they used to + // run back to back on the main thread — up to ~13s of serial waits on + // a slow disk, well past the app-hang watchdog. Fan them out and wait + // once: every flush blocks on its own queue's semaphore, so they + // drain concurrently and the quit pays only the slowest one, capped + // by the group deadline below. + // + // Aptabase rides along: it batches analytics in memory and its own + // `willTerminate` flush is async, which `_exit(0)` would skip. + // `prepareQuitFlush` reads the consent gates on main and hands back the + // blocking send-and-wait for a worker thread while the group waits. + // + // Budgets: the two 3.0s-default drains (Keychain, ConfigDiskWriter) + // get an explicit sub-cap so they can finish inside the group deadline + // — a slow securityd write that lands at 2.4s must still be honored, + // not cut off by `_exit`. The deadline itself stays under the 3.0s + // app-hang watchdog so a timed-out flush isn't filed as a hang. No-op unless telemetry is consented. + let flushGroup = DispatchGroup() + let flushWorkers = DispatchQueue.global(qos: .userInitiated) + var flushes: [@Sendable () -> Void] = [ + { ToolConfigurationStore.flushPendingWrites() }, + { ComputerUsePolicyStore.flushPendingWrites() }, + { SandboxConfigurationStore.flushPendingWrites() }, + { SubagentConfigurationStore.flushPendingWrites() }, + { ConfigDiskWriter.flushPendingWrites(timeout: 2.5) }, + { Keychain.flushPendingWrites(timeout: 2.5) }, + ] + if let telemetryFlush = TelemetryService.shared.prepareQuitFlush() { + flushes.append(telemetryFlush) + } + for flush in flushes { + flushGroup.enter() + flushWorkers.async { + flush() + flushGroup.leave() + } + } + if flushGroup.wait(timeout: .now() + 2.8) == .timedOut { + NSLog("Osaurus quit flush timed out; exiting with writes possibly pending") + } // Hard-exit without running `atexit`/C++ static destructors. // AppKit's `terminate:` would otherwise call `exit()`, which runs @@ -1614,13 +1646,13 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega serverController.$serverHealth .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.updateStatusItemAndMenu() + self?.scheduleStatusItemUpdate() } .store(in: &cancellables) serverController.$isRunning .receive(on: RunLoop.main) .sink { [weak self] isRunning in - self?.updateStatusItemAndMenu() + self?.scheduleStatusItemUpdate() if isRunning { self?.completeFirstSuccessfulServerStart() } @@ -1629,14 +1661,14 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega serverController.$configuration .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.updateStatusItemAndMenu() + self?.scheduleStatusItemUpdate() } .store(in: &cancellables) serverController.$activeRequestCount .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.updateStatusItemAndMenu() + self?.scheduleStatusItemUpdate() } .store(in: &cancellables) @@ -1644,7 +1676,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega VADService.shared.$state .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.updateStatusItemAndMenu() + self?.scheduleStatusItemUpdate() } .store(in: &cancellables) @@ -1689,6 +1721,25 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega } } + /// Coalesces the status-item refresh to one pass per runloop turn. Five + /// publishers funnel into it, and `$activeRequestCount` alone can fire + /// several times in a single turn under request churn — each pass detaches + /// the menu, re-sets the button image, and rebuilds the tooltip, which is + /// enough WindowServer traffic to stall main when the machine is already + /// slow. The flag resets before the update runs, so a publish that lands + /// during the update still schedules a fresh pass and no state is missed. + private var statusItemUpdateScheduled = false + + private func scheduleStatusItemUpdate() { + guard !statusItemUpdateScheduled else { return } + statusItemUpdateScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.statusItemUpdateScheduled = false + self.updateStatusItemAndMenu() + } + } + private func updateStatusItemAndMenu() { guard let statusItem else { return } // Ensure no NSMenu is attached so button action is triggered diff --git a/Packages/OsaurusCore/ComputerUse/Policy/ComputerUsePolicyStore.swift b/Packages/OsaurusCore/ComputerUse/Policy/ComputerUsePolicyStore.swift index c272545234..529c29f5db 100644 --- a/Packages/OsaurusCore/ComputerUse/Policy/ComputerUsePolicyStore.swift +++ b/Packages/OsaurusCore/ComputerUse/Policy/ComputerUsePolicyStore.swift @@ -49,7 +49,9 @@ public enum ComputerUsePolicyStore { } /// Synchronously drain any pending write (call from `applicationWillTerminate`). - public static func flushPendingWrites(timeout: TimeInterval = 1.5) { + /// `nonisolated` so the quit path can drain all stores concurrently off + /// main; the coordinator is internally locked. + nonisolated public static func flushPendingWrites(timeout: TimeInterval = 1.5) { writeCoordinator.flushSync(timeout: timeout) } @@ -60,7 +62,7 @@ public enum ComputerUsePolicyStore { return OsaurusPaths.computerUseConfigFile() } - private static let writeCoordinator = WriteCoordinator() + nonisolated private static let writeCoordinator = WriteCoordinator() private final class WriteCoordinator: @unchecked Sendable { private let queue = DispatchQueue(label: "com.osaurus.computeruse.write", qos: .utility) diff --git a/Packages/OsaurusCore/Configuration/Declarative/ConfigApplier.swift b/Packages/OsaurusCore/Configuration/Declarative/ConfigApplier.swift index 062397fb7f..e238a56242 100644 --- a/Packages/OsaurusCore/Configuration/Declarative/ConfigApplier.swift +++ b/Packages/OsaurusCore/Configuration/Declarative/ConfigApplier.swift @@ -692,7 +692,7 @@ enum ConfigApplier { // in results — only the ref display names do. var botToken: (value: String, display: String)? if let raw = section.botTokenRef { - let (secret, display) = resolveSecretRef(raw) + let (secret, display) = await resolveSecretRef(raw) guard let secret else { results.append( ConfigApplyResult( @@ -705,7 +705,7 @@ enum ConfigApplier { } var appToken: (value: String, display: String)? if let raw = section.appTokenRef { - let (secret, display) = resolveSecretRef(raw) + let (secret, display) = await resolveSecretRef(raw) guard let secret else { results.append( ConfigApplyResult( @@ -800,12 +800,31 @@ enum ConfigApplier { @MainActor private static func applyMCPServers( _ entries: [MCPServerEntry], prune: Bool - ) -> [ConfigApplyResult] { + ) async -> [ConfigApplyResult] { var results: [ConfigApplyResult] = [] let manager = MCPProviderManager.shared var matched = Set() for entry in entries { + // Resolve every secret reference up front. `resolveSecretRef` + // suspends (a detached Keychain read that can take seconds), and + // the existing-provider branch below copies the live provider, + // mutates the copy, and writes the whole struct back — a + // suspension inside that window would silently revert any edit + // Settings saved meanwhile. With the awaits hoisted here, the + // read-mutate-write below is synchronous on the main actor again. + var resolvedToken: (secret: String?, display: String)? + if let raw = entry.tokenRef { + resolvedToken = await resolveSecretRef(raw) + } + var resolvedEnvRefs: [(key: String, secret: String?, display: String)] = [] + if let refs = entry.secretEnvRefs { + for (envKey, raw) in refs.sorted(by: { $0.key < $1.key }) { + let (secret, display) = await resolveSecretRef(raw) + resolvedEnvRefs.append((envKey, secret, display)) + } + } + let existing = ConfigExporter.manageableMCPProviders().first { $0.name.lowercased() == entry.name.lowercased() } @@ -823,8 +842,7 @@ enum ConfigApplier { } // A token reference stores the bearer token directly — // no Settings visit needed when it resolves and lands. - if let raw = entry.tokenRef { - let (secret, display) = resolveSecretRef(raw) + if case let (secret, display)? = resolvedToken { if let secret { provider.authType = .bearerToken if MCPProviderKeychain.saveToken(secret, for: provider.id) { @@ -858,9 +876,8 @@ enum ConfigApplier { { provider.executionHost = host } - if let refs = entry.secretEnvRefs { - for (envKey, raw) in refs.sorted(by: { $0.key < $1.key }) { - let (secret, display) = resolveSecretRef(raw) + if !resolvedEnvRefs.isEmpty { + for (envKey, secret, display) in resolvedEnvRefs { guard let secret else { secretFailure = true secretMessages.append( @@ -921,8 +938,7 @@ enum ConfigApplier { // a bad reference never leaves a half-configured server. var resolvedSecretEnv: [(key: String, value: String, display: String)] = [] var unresolved: [String] = [] - for (envKey, raw) in (entry.secretEnvRefs ?? [:]).sorted(by: { $0.key < $1.key }) { - let (secret, display) = resolveSecretRef(raw) + for (envKey, secret, display) in resolvedEnvRefs { if let secret { resolvedSecretEnv.append((envKey, secret, display)) } else { @@ -987,8 +1003,7 @@ enum ConfigApplier { var auth = entry.auth.flatMap(ConfigMCPAuth.auth(forKey:)) ?? MCPProviderAuthType.none var token: String? = nil var tokenDisplay: String? = nil - if let raw = entry.tokenRef { - let (secret, display) = resolveSecretRef(raw) + if case let (secret, display)? = resolvedToken { guard let secret else { results.append( ConfigApplyResult( @@ -1194,7 +1209,7 @@ enum ConfigApplier { // sheet. Mirrors the interactive path: entering a key flips // the provider to API-key auth. if let raw = entry.apiKeyRef { - let (secret, display) = resolveSecretRef(raw) + let (secret, display) = await resolveSecretRef(raw) guard let secret else { results.append( ConfigApplyResult( @@ -1203,12 +1218,19 @@ enum ConfigApplier { + "nothing was stored.")) continue } - var updated = frozen - updated.authType = .apiKey - let toSave = updated + // Re-read the provider after the suspension: `frozen` is a + // pre-await copy, and writing it back would revert any + // Settings edit that landed while the Keychain read ran. + // Only the auth flip is ours to write. + let providerId = frozen.id await MainActor.run { + guard + var current = RemoteProviderManager.shared.configuration.providers + .first(where: { $0.id == providerId }) + else { return } + current.authType = .apiKey RemoteProviderManager.shared.updateProvider( - toSave, apiKey: secret, oauthTokens: nil) + current, apiKey: secret, oauthTokens: nil) } results.append( ConfigApplyResult( @@ -1273,11 +1295,22 @@ enum ConfigApplier { /// Resolve a `*_ref` document value into (secret, safe display name). /// `nil` secret means malformed / missing / empty — callers report by /// display only; the value itself never reaches a result or a log. - static func resolveSecretRef(_ raw: String) -> (secret: String?, display: String) { + static func resolveSecretRef(_ raw: String) async -> (secret: String?, display: String) { switch ConfigSecretRef.parse(raw) { case .failure: return (nil, raw) case .success(let ref): + // Keychain refs reach securityd through a synchronous + // SecItemCopyMatching; resolved on the main actor, that IPC + // round-trip has stalled the app for seconds when securityd was + // slow. Hop off the cooperative executor for the read. Env refs + // are a dictionary lookup and stay inline. + if case .keychain = ref.source { + let secret = await Task.detached(priority: .userInitiated) { + ref.resolve() + }.value + return (secret, ref.display) + } return (ref.resolve(), ref.display) } } @@ -1378,7 +1411,7 @@ enum ConfigApplier { // is read from env/keychain and stored exactly like an entered one. // Validation refuses api_key_ref for the OAuth/pairing resolutions. if let raw = entry.apiKeyRef, case .preset = resolution { - let (secret, display) = resolveSecretRef(raw) + let (secret, display) = await resolveSecretRef(raw) guard let secret else { return ProviderAddOutcome( result: ConfigApplyResult( diff --git a/Packages/OsaurusCore/Managers/Chat/ChatSessionsManager.swift b/Packages/OsaurusCore/Managers/Chat/ChatSessionsManager.swift index c30b288d82..b84769b801 100644 --- a/Packages/OsaurusCore/Managers/Chat/ChatSessionsManager.swift +++ b/Packages/OsaurusCore/Managers/Chat/ChatSessionsManager.swift @@ -22,7 +22,44 @@ final class ChatSessionsManager: ObservableObject { private var cancellables: Set = [] + /// Whether `shared` has been created (and its synchronous first load + /// paid). Lets the launch prewarm decide to warm the read path first + /// instead of letting a speculative view construction be the first + /// toucher. + private(set) static var isInstantiated = false + + /// Launch-prewarm entry point: create `shared` with the database queue + /// warmed. The first toucher of `shared` pays a synchronous + /// `loadAll()` on the main actor, and when that toucher is the + /// speculative ChatView prewarm, `queue.sync` inside the database can + /// park the launch main thread behind whatever the serial queue is + /// busy with (post-open maintenance, a checkpoint, a large write). + /// Draining the queue once off the main actor first means init's load + /// runs against an idle queue. The sync-load-in-init contract for real + /// windows is unchanged — this only reorders who pays first. + /// + /// Same readiness gate as `prewarmChatView`: with encrypted storage and + /// the key not yet resident, `ensureOpen` returns before scheduling any + /// open, and nothing later reloads a manager created empty — the first + /// real window would show an empty sidebar. Skipping is safe; the first + /// window creates `shared` on demand once the key is resident. + static func prewarmShared() async { + guard !isInstantiated else { return } + guard StorageKeyManager.shared.isStorageReadyForWrites else { return } + if ChatHistoryDatabase.shared.isOpenNonBlocking { + let db = ChatHistoryDatabase.shared + // `isOpen` is a `queue.sync` no-op: it waits behind whatever the + // serial queue is busy with without paying a full metadata decode + // that init would only repeat. + _ = await Task.detached(priority: .userInitiated) { + db.isOpen + }.value + } + _ = ChatSessionsManager.shared + } + private init() { + Self.isInstantiated = true // Load synchronously so the first reader (ChatWindowState.init) // sees populated sessions. Deferring this via Task caused the // sidebar to render empty on first open until something else diff --git a/Packages/OsaurusCore/Managers/Model/ModelPickerItemCache.swift b/Packages/OsaurusCore/Managers/Model/ModelPickerItemCache.swift index bf75b410fd..48b36780f3 100644 --- a/Packages/OsaurusCore/Managers/Model/ModelPickerItemCache.swift +++ b/Packages/OsaurusCore/Managers/Model/ModelPickerItemCache.swift @@ -18,9 +18,18 @@ final class ModelPickerItemCache: ObservableObject { /// in flight. Concurrent rebuild requests are coalesced through a single /// in-flight Task so that the "last writer wins" race that previously caused /// remote-provider models to disappear at launch can no longer occur. - @Published private(set) var items: [ModelPickerItem] = [] + @Published private(set) var items: [ModelPickerItem] = [] { + didSet { chatModelCandidates = items.chatModelCandidates } + } @Published private(set) var isLoaded = false + /// Stored projection of `items.chatModelCandidates`, recomputed once per + /// rebuild. The filter runs `isLikelyChatCapable` string matching over + /// every item, and the subagent model pickers read the candidate list in + /// their view bodies — per-render recomputation there multiplies that + /// scan by every graph update while the picker is on screen. + private(set) var chatModelCandidates: [ModelPickerItem] = [] + /// Whether at least one ready text-to-image model is installed. A synchronous /// read off the already-warmed picker cache, used by the subagent gate to /// decide whether the `image` tool is injected at all (no image model -> diff --git a/Packages/OsaurusCore/Models/Chat/FileDiff.swift b/Packages/OsaurusCore/Models/Chat/FileDiff.swift index 34a40dae8d..104cc36039 100644 --- a/Packages/OsaurusCore/Models/Chat/FileDiff.swift +++ b/Packages/OsaurusCore/Models/Chat/FileDiff.swift @@ -149,9 +149,19 @@ struct FileDiff: Equatable { fallbackPath: String? = nil ) -> FileDiff? { guard diffProducingToolNames.contains(toolName) else { return nil } + // Live previews rescan the whole arg buffer on every streaming + // delta, so an uncapped buffer makes the stream's total scan cost + // quadratic — a multi-hundred-KB file write has hung the main + // thread this way. Past the cap the card simply stops growing (the + // streaming badge stays up) and the real diff replaces it when the + // call lands. `truncated` is left alone: it is the executor's + // "diff text was capped" flag, not a preview-scan condition. + // One-shot previews (failed writes) scan the full payload: they + // run once, and that card is all the user ever gets. + let scanArgs = isStreaming ? cappedScanText(partialArgs) : partialArgs guard - let body = partialStringField("content", in: partialArgs) - ?? partialStringField("new_string", in: partialArgs), + let body = partialStringField("content", in: scanArgs) + ?? partialStringField("new_string", in: scanArgs), !body.isEmpty else { return nil } @@ -164,7 +174,7 @@ struct FileDiff: Equatable { // any case/separator variant the raw-text scan here can't. let pathKeys = ["path"] + (SchemaValidator.keySynonyms["path"] ?? []) + ["filePath", "fileName"] - var path = pathKeys.lazy.compactMap { partialStringField($0, in: partialArgs) } + var path = pathKeys.lazy.compactMap { partialStringField($0, in: scanArgs) } .first(where: { !$0.isEmpty }) ?? "" if path.isEmpty, toolName == "file_edit", let fallback = fallbackPath, !fallback.isEmpty { path = fallback @@ -183,6 +193,23 @@ struct FileDiff: Equatable { ) } + /// Per-delta scan budget for live previews (UTF-8 bytes). ~256 KB is + /// thousands of preview lines — far past what the card can usefully + /// show — while keeping each delta's rescan cost bounded. + private static let streamingScanCapUTF8 = 1 << 18 + + /// The scan window for one live-preview pass: the full text while it is + /// small, a fixed-size prefix once it isn't. Bounding the window is what + /// turns per-delta cost from O(stream so far) into O(cap). + private static func cappedScanText(_ text: String) -> String { + guard text.utf8.count > streamingScanCapUTF8 else { return text } + // Byte-bounded via the UTF-8 view (O(1) index) — `String.prefix` counts + // Characters, which neither bounds multibyte input nor matches the + // guard above. A split scalar at the cut decodes to a replacement + // character, harmless in a discarded tail. + return String(decoding: text.utf8.prefix(streamingScanCapUTF8), as: UTF8.self) + } + /// Identifies which known file a still-streaming `file_edit` targets when /// its `path` argument hasn't streamed yet, by matching the streamed /// `old_string` prefix against the contents of files already seen in the @@ -196,7 +223,12 @@ struct FileDiff: Equatable { partialArgs: String, knownFiles: [(path: String, content: String)] ) -> String? { - guard let excerpt = partialStringField("old_string", in: partialArgs), + // Same per-delta scan budget as the live preview: this runs on every + // streaming delta too, and both the arg scan and the `contains` + // probe below grow with the excerpt. An `old_string` that starts + // beyond the cap just leaves the card on its placeholder name. + let scanArgs = cappedScanText(partialArgs) + guard let excerpt = partialStringField("old_string", in: scanArgs), excerpt.count >= inferenceMinExcerptLength else { return nil } var match: String? diff --git a/Packages/OsaurusCore/Models/Tool/ToolConfigurationStore.swift b/Packages/OsaurusCore/Models/Tool/ToolConfigurationStore.swift index 8b25b5bcb4..ff36068050 100644 --- a/Packages/OsaurusCore/Models/Tool/ToolConfigurationStore.swift +++ b/Packages/OsaurusCore/Models/Tool/ToolConfigurationStore.swift @@ -45,8 +45,9 @@ enum ToolConfigurationStore { /// Synchronously drain any pending background write. Call from /// `applicationWillTerminate` before `_exit` so a toggle made moments before - /// quitting still lands on disk. - static func flushPendingWrites(timeout: TimeInterval = 1.5) { + /// quitting still lands on disk. `nonisolated` so the quit path can drain + /// all stores concurrently off main; the coordinator is internally locked. + nonisolated static func flushPendingWrites(timeout: TimeInterval = 1.5) { writeCoordinator.flushSync(timeout: timeout) } @@ -58,7 +59,7 @@ enum ToolConfigurationStore { } /// Serial, coalescing background writer for `tools.json`. - private static let writeCoordinator = WriteCoordinator() + nonisolated private static let writeCoordinator = WriteCoordinator() private final class WriteCoordinator: @unchecked Sendable { private let queue = DispatchQueue(label: "com.osaurus.toolconfig.write", qos: .utility) diff --git a/Packages/OsaurusCore/Networking/RelayTunnelManager.swift b/Packages/OsaurusCore/Networking/RelayTunnelManager.swift index 1684188654..a2fe8b6874 100644 --- a/Packages/OsaurusCore/Networking/RelayTunnelManager.swift +++ b/Packages/OsaurusCore/Networking/RelayTunnelManager.swift @@ -331,6 +331,13 @@ public final class RelayTunnelManager: ObservableObject { private func connect() async { guard webSocketTask == nil || !isConnected else { return } + // Identity of whatever task was live when this connect started. The + // suspensions below (master key, session build) are windows in which + // another connect can publish a new socket; `isConnected` only flips + // in `handleAuthOk`, so a guard on it alone passes for the whole + // handshake and let two overlapping connects publish two sockets. + // Re-checking against this identity catches the overlap exactly. + let taskAtEntry = webSocketTask let enabled = configuration.enabledAgentIds guard !enabled.isEmpty else { return } @@ -358,9 +365,21 @@ public final class RelayTunnelManager: ObservableObject { } // Re-check after the suspension: another connect may have won the race - guard webSocketTask == nil || !isConnected else { return } + guard webSocketTask === taskAtEntry else { return } - let session = Self.makeWebSocketSession() + // Detached: building the session reads the disk-backed proxy/server + // configuration (a stat plus a possible file read + decode under the + // config cache lock), which has stalled the main actor on slow disks. + let session = await Task.detached(priority: .userInitiated) { + Self.makeWebSocketSession() + }.value + // The detached hop is another suspension a racing connect could have + // crossed; re-check before publishing the new task. + guard webSocketTask === taskAtEntry else { return } + // A stale, never-authenticated task from a failed handshake is what + // let the entry guard through; retire it so it can't leak. + taskAtEntry?.cancel(with: .goingAway, reason: nil) + urlSession?.invalidateAndCancel() let task = session.webSocketTask(with: Self.relayURL) self.urlSession = session self.webSocketTask = task @@ -422,8 +441,13 @@ public final class RelayTunnelManager: ObservableObject { guard let task = self.webSocketTask else { break } do { let message = try await task.receive() + // A socket retired by a newer connect must neither feed + // frames into the live session nor tear it down when its + // final receive fails; only the current task may. + guard self.webSocketTask === task else { break } self.handleMessage(message) } catch { + guard self.webSocketTask === task else { break } self.handleDisconnect() break } diff --git a/Packages/OsaurusCore/Services/Chat/ContextBudgetManager.swift b/Packages/OsaurusCore/Services/Chat/ContextBudgetManager.swift index c566e90b1f..e9b62962db 100644 --- a/Packages/OsaurusCore/Services/Chat/ContextBudgetManager.swift +++ b/Packages/OsaurusCore/Services/Chat/ContextBudgetManager.swift @@ -232,7 +232,7 @@ public struct ContextBudgetManager: Sendable { } } for (_, result) in turn.toolResults { - t += max(1, result.count / TokenEstimator.charsPerToken) + t += TokenEstimator.estimate(result) } if turn.hasThinking { t += max(1, turn.thinkingLength / TokenEstimator.charsPerToken) @@ -331,6 +331,15 @@ public struct ContextBudgetManager: Sendable { public static let trimmedHistoryNote = "[Note: Earlier messages were trimmed to fit the context window. The original task and recent actions are preserved.]" + /// The counted context note the stateless trim path inserts after a drop. + static func statelessTrimNote(droppedCount: Int) -> ChatMessage { + ChatMessage( + role: "user", + content: + "[Note: \(droppedCount) earlier messages were trimmed to fit context window. The original task and recent actions are preserved.]" + ) + } + /// Sticky trim variant that also reports whether the transcript STILL /// exceeds the history budget after every compaction lever (summaries, /// drops) is exhausted — i.e. the protected first message + tail alone @@ -577,7 +586,17 @@ public struct ContextBudgetManager: Sendable { let middle = Array(trimmed[firstMessageCount ..< protectedTailStart]) let units = Self.groupIntoUnits(middle) var keptUnits: [[ChatMessage]] = [] - var runningTokens = Self.estimateTokens(for: result) + Self.estimateTokens(for: tail) + // Reaching Phase 2 means the fully-summarized transcript is still + // over budget, so at least one unit WILL be dropped and the context + // note WILL be inserted. Reserve the note's cost before deciding what + // fits; sizing it with `middle.count` (the largest count the note can + // carry) keeps the reservation a safe upper bound. Leaving it out let + // the keep loop fill the budget exactly and then push the note on top. + let noteReservation = Self.estimateTokens( + forMessage: Self.statelessTrimNote(droppedCount: middle.count) + ) + var runningTokens = + Self.estimateTokens(for: result) + Self.estimateTokens(for: tail) + noteReservation for unit in units.reversed() { let unitTokens = Self.estimateTokens(for: unit) @@ -593,12 +612,7 @@ public struct ContextBudgetManager: Sendable { // If we dropped some middle messages, insert a context note if middleToKeep.count < middle.count { let droppedCount = middle.count - middleToKeep.count - let contextNote = ChatMessage( - role: "user", - content: - "[Note: \(droppedCount) earlier messages were trimmed to fit context window. The original task and recent actions are preserved.]" - ) - result.append(contextNote) + result.append(Self.statelessTrimNote(droppedCount: droppedCount)) } result.append(contentsOf: middleToKeep) diff --git a/Packages/OsaurusCore/Services/Chat/TokenEstimator.swift b/Packages/OsaurusCore/Services/Chat/TokenEstimator.swift index 870acfdcb8..7ef61a99a1 100644 --- a/Packages/OsaurusCore/Services/Chat/TokenEstimator.swift +++ b/Packages/OsaurusCore/Services/Chat/TokenEstimator.swift @@ -52,13 +52,24 @@ public enum TokenEstimator { /// 1-char strings don't silently round to zero tokens. public static func estimate(_ text: String?) -> Int { guard let text, !text.isEmpty else { return 0 } - return max(1, text.count / charsPerToken) + // `utf8.count`, not `count`: Character counting walks the whole + // string grapheme by grapheme, and the budget pipeline re-estimates + // entire conversations (every tool result included) inside view-body + // evaluation — O(total transcript) per render was showing up as + // main-thread hangs. The UTF-8 length is stored on native strings + // (O(1)), identical for ASCII, and if anything a fairer proxy for + // dense scripts, whose tokenizers see bytes-per-token closer to this. + return max(1, text.utf8.count / charsPerToken) } /// Estimate tokens for a single tool-call envelope. `id` defaults to /// "" because some callers (streaming deltas) only have the function /// name + arguments and not the synthetic call id. public static func toolCallTokens(name: String, arguments: String, id: String = "") -> Int { - max(1, (name.count + arguments.count + id.count + toolCallEnvelopeChars) / charsPerToken) + // utf8.count for the same O(1) reason as `estimate`. + max( + 1, + (name.utf8.count + arguments.utf8.count + id.utf8.count + toolCallEnvelopeChars) + / charsPerToken) } } diff --git a/Packages/OsaurusCore/Services/Keychain/AgentSecretsKeychain.swift b/Packages/OsaurusCore/Services/Keychain/AgentSecretsKeychain.swift index 532ef88c42..d90d14229f 100644 --- a/Packages/OsaurusCore/Services/Keychain/AgentSecretsKeychain.swift +++ b/Packages/OsaurusCore/Services/Keychain/AgentSecretsKeychain.swift @@ -130,7 +130,7 @@ public enum AgentSecretsKeychain { } if KeychainQueryHelpers.disablesKeychainForProcess { return false } let didWrite = Keychain.write(service: service, account: account, data: valueData) - if didWrite { invalidateAccountsCache() } + if didWrite { noteAccountSaved(account) } return didWrite } @@ -156,7 +156,7 @@ public enum AgentSecretsKeychain { } if KeychainQueryHelpers.disablesKeychainForProcess { return true } let didDelete = Keychain.delete(service: service, account: account) - if didDelete { invalidateAccountsCache() } + if didDelete { noteAccountsRemoved { $0 == account } } return didDelete } @@ -179,9 +179,19 @@ public enum AgentSecretsKeychain { /// Prompt construction only needs to tell the model which secret names are /// available. Fetching the values here is both unnecessary and can hit the /// slow Keychain data-decryption path during ordinary chat composition. + /// Secret *names* for `agentId`, for listing in the system prompt so the + /// model knows which env vars exist. Non-blocking: this is reached + /// synchronously from chat-preview composition on the main actor, and a + /// cold-cache enumeration takes the process-wide Keychain lock (the + /// SecItemCopyMatching that hung the UI). On a cold cache we kick the + /// background seed and return `[]`; the next compose (seed lands in ms, + /// and composes re-run on every budget/input change) lists the real + /// names. The value paths that must be exact — `getAllSecrets` for env + /// injection, `deleteAllSecrets` — still go through the blocking + /// `allAccounts()`. public static func secretIDs(agentId: UUID) -> [String] { let prefix = "\(agentId.uuidString)." - return allAccounts() + return cachedAccountsOrSeed() .filter { $0.hasPrefix(prefix) } .map { String($0.dropFirst(prefix.count)) } .sorted() @@ -202,7 +212,7 @@ public enum AgentSecretsKeychain { for account in allAccounts() where account.hasPrefix(prefix) { Keychain.delete(service: service, account: account) } - invalidateAccountsCache() + noteAccountsRemoved { $0.hasPrefix(prefix) } } // MARK: - Environment Safety @@ -236,20 +246,76 @@ public enum AgentSecretsKeychain { /// composition runs on the main actor) finds a warm cache instead of a /// blocking `SecItemCopyMatching` + `LAContext` round-trip. public static func prewarmAccounts() { + // Single-flight: a cold read can be reached from every compose while + // the seed is still in flight (or from every compose forever while + // the keychain is locked and the enumeration stays non-definitive), + // and each spawn would take the process-wide Keychain lock again. + accountsCacheLock.lock() + let shouldSeed = cachedAccounts == nil && !seedInFlight + if shouldSeed { seedInFlight = true } + accountsCacheLock.unlock() + guard shouldSeed else { return } Task.detached(priority: .utility) { _ = allAccounts() + clearSeedInFlight() } } + /// Synchronous so the detached seed can take the lock; bare `lock()` / + /// `unlock()` are unavailable inside async contexts. + private static func clearSeedInFlight() { + accountsCacheLock.lock() + seedInFlight = false + accountsCacheLock.unlock() + } + // MARK: - Private private static let accountsCacheLock = NSLock() nonisolated(unsafe) private static var cachedAccounts: [String]? - /// Drop the account-name memo after a mutation so the next read re-queries. - private static func invalidateAccountsCache() { + /// Return the memoized account names without ever blocking on the + /// Keychain. Warm cache → the names. Cold cache → kick the background + /// seed and return `[]` (the caller re-reads on a later pass). The + /// in-memory test store and disabled-keychain postures resolve inline + /// since neither can block. Backs the display-only `secretIDs` reader. + private static func cachedAccountsOrSeed() -> [String] { + if let accounts = testingAllAccounts() { + return accounts + } + if KeychainQueryHelpers.disablesKeychainForProcess { return [] } accountsCacheLock.lock() - cachedAccounts = nil + let cached = cachedAccounts + accountsCacheLock.unlock() + if let cached { + return cached + } + prewarmAccounts() + return [] + } + + nonisolated(unsafe) private static var seedInFlight = false + + /// Maintain the memo in place after a write rather than dropping it: the + /// non-blocking `secretIDs` reader would otherwise answer `[]` for every + /// compose between the mutation and the re-seed — including the very send + /// that follows a `store_secret` — telling the model no secrets exist on + /// exactly the turns that matter. Account names only change through this + /// type, so the edit is exact. A never-seeded memo stays nil. + private static func noteAccountSaved(_ account: String) { + accountsCacheLock.lock() + if var cached = cachedAccounts, !cached.contains(account) { + cached.append(account) + cachedAccounts = cached + } + accountsCacheLock.unlock() + } + + private static func noteAccountsRemoved(where shouldRemove: (String) -> Bool) { + accountsCacheLock.lock() + if let cached = cachedAccounts { + cachedAccounts = cached.filter { !shouldRemove($0) } + } accountsCacheLock.unlock() } @@ -269,7 +335,7 @@ public enum AgentSecretsKeychain { // `SecItemCopyMatching` takes a process-wide Keychain lock and has hung // the UI when reached from `secretIDs` during chat-preview composition // on the main thread. Account names change only through this type's own - // writes, so memoize the enumeration and invalidate it on every + // writes, so memoize the enumeration and maintain it in place on every // mutation. Only a definitive enumeration is cached: a locked or // transiently failing keychain must not latch an empty account list. let outcome = Keychain.fetchAllItems(service: service, returnData: false) diff --git a/Packages/OsaurusCore/Services/ModelDownloadService.swift b/Packages/OsaurusCore/Services/ModelDownloadService.swift index 333a85b399..0b89fc2d59 100644 --- a/Packages/OsaurusCore/Services/ModelDownloadService.swift +++ b/Packages/OsaurusCore/Services/ModelDownloadService.swift @@ -561,20 +561,27 @@ final class ModelDownloadService: ObservableObject { // multi shard download with a silently skipped file would // still pass that test. Verify every manifest entry is on // disk at its expected size and report which are missing - let fm = FileManager.default - let missing: [String] = files.compactMap { file in - guard - let dest = HuggingFaceService.destinationURL( - forRemotePath: file.path, - under: model.localDirectory - ) - else { - return file.path + // Detached: this orchestration Task inherits the service's + // main-actor isolation, and the check below stats every + // manifest entry (containment probes + size read per file) — + // dozens of disk touches that have stalled main on slow + // volumes when run inline. + let missing: [String] = await Task.detached(priority: .userInitiated) { + let fm = FileManager.default + return files.compactMap { file in + guard + let dest = HuggingFaceService.destinationURL( + forRemotePath: file.path, + under: model.localDirectory + ) + else { + return file.path + } + let attrs = try? fm.attributesOfItem(atPath: dest.path) + let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 + return size == file.size ? nil : file.path } - let attrs = try? fm.attributesOfItem(atPath: dest.path) - let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 - return size == file.size ? nil : file.path - } + }.value let isComplete = missing.isEmpty let finalState: DownloadState if isComplete { @@ -589,15 +596,19 @@ final class ModelDownloadService: ObservableObject { "Download incomplete: \(missing.count) of \(files.count) files are missing or have wrong size" ) } + // Also detached: the report reads config/tokenizer files out + // of the bundle it diagnoses. let compatibilityReport = isComplete - ? ModelCompatibilityDiagnostics.report( - modelId: model.id, - modelName: model.name, - modelTypeHint: model.modelType, - bundleURL: model.localDirectory, - externalSource: model.externalSource - ) + ? await Task.detached(priority: .userInitiated) { + ModelCompatibilityDiagnostics.report( + modelId: model.id, + modelName: model.name, + modelTypeHint: model.modelType, + bundleURL: model.localDirectory, + externalSource: model.externalSource + ) + }.value : nil await MainActor.run { let didFinalize = self.finalizeOrchestration( diff --git a/Packages/OsaurusCore/Services/ModelRuntime/ImageModelDownloadService.swift b/Packages/OsaurusCore/Services/ModelRuntime/ImageModelDownloadService.swift index 550a8d4cbf..fb02a8ed7e 100644 --- a/Packages/OsaurusCore/Services/ModelRuntime/ImageModelDownloadService.swift +++ b/Packages/OsaurusCore/Services/ModelRuntime/ImageModelDownloadService.swift @@ -361,15 +361,31 @@ final class ImageModelDownloadService: ObservableObject { total: Int64 ) async throws { try Task.checkCancellation() + // Containment validation stats every existing path component (plus + // symlink probes) and the resume check stats the destination — once + // per file, against whatever disk holds the models directory. This + // method runs on the main actor for bookkeeping, so hop the disk + // probes off it; they have stalled main on slow disks. + let filePath = file.path + let expectedSize = file.size + let staged: (destination: URL, alreadyComplete: Bool)? = + await Task.detached(priority: .userInitiated) { + guard + let destination = HuggingFaceService.destinationURL( + forRemotePath: filePath, under: root) + else { return nil } + let existing = + try? FileManager.default.attributesOfItem(atPath: destination.path)[.size] + as? Int64 + return (destination, existing == expectedSize) + }.value guard - let destination = HuggingFaceService.destinationURL(forRemotePath: file.path, under: root), + let (destination, alreadyComplete) = staged, let url = ModelDownloadService.resolveURL(repoId: repoId, path: file.path) else { return } // Skip files already present at the expected size (resume). - if let existing = try? FileManager.default.attributesOfItem(atPath: destination.path)[.size] - as? Int64, existing == file.size - { + if alreadyComplete { liveBytes[dirName, default: [:]][file.path] = file.size updateProgress(dirName, total: total) return diff --git a/Packages/OsaurusCore/Services/SwapPressureMonitor.swift b/Packages/OsaurusCore/Services/SwapPressureMonitor.swift index 199c0b1f97..54f3ecfea5 100644 --- a/Packages/OsaurusCore/Services/SwapPressureMonitor.swift +++ b/Packages/OsaurusCore/Services/SwapPressureMonitor.swift @@ -457,14 +457,43 @@ public final class SwapPressureMonitor: @unchecked Sendable { { return severity } - let root = dataRoot ?? OsaurusPaths.root() - let flag = root.appendingPathComponent("debug/swap-emulate") - guard let raw = try? String(contentsOf: flag, encoding: .utf8) else { - return nil + // The flag file is read on a background queue and memoized: + // `currentState()` runs on the caller's thread — in practice the chat + // card's main-thread 2s tick — and a synchronous read here can park + // for seconds on exactly the swap-thrashed disk this monitor exists + // to detect. The memo means a flag edit lands one tick late, which + // the "re-read every sample" contract tolerates. + if let dataRoot { + // Explicit roots come from tests; keep them synchronous and + // un-memoized so suites see their own fixture immediately. + let flag = dataRoot.appendingPathComponent("debug/swap-emulate") + guard let raw = try? String(contentsOf: flag, encoding: .utf8) else { return nil } + return parseEmulation(raw) + } + emulationFlagLock.lock() + let memo = emulationFlagMemo + let refreshInFlight = emulationFlagRefreshInFlight + if !refreshInFlight { emulationFlagRefreshInFlight = true } + emulationFlagLock.unlock() + if !refreshInFlight { + DispatchQueue.global(qos: .utility).async { + let flag = OsaurusPaths.root().appendingPathComponent("debug/swap-emulate") + let severity = (try? String(contentsOf: flag, encoding: .utf8)) + .flatMap(parseEmulation) + emulationFlagLock.lock() + emulationFlagMemo = severity + emulationFlagRefreshInFlight = false + emulationFlagLock.unlock() + } } - return parseEmulation(raw) + return memo } + // nonisolated(unsafe): every read and write is inside emulationFlagLock. + private static let emulationFlagLock = NSLock() + nonisolated(unsafe) private static var emulationFlagMemo: Severity? + nonisolated(unsafe) private static var emulationFlagRefreshInFlight = false + static func parseEmulation(_ raw: String) -> Severity? { switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "elevated", "warn", "warning": .elevated diff --git a/Packages/OsaurusCore/Services/TelemetryService.swift b/Packages/OsaurusCore/Services/TelemetryService.swift index 8adeded930..17817e289c 100644 --- a/Packages/OsaurusCore/Services/TelemetryService.swift +++ b/Packages/OsaurusCore/Services/TelemetryService.swift @@ -98,19 +98,19 @@ public final class TelemetryService { started = true } - /// Best-effort synchronous flush for the quit path. Aptabase's send queue - /// is in-memory only (no disk persistence), and the app now hard-exits with - /// `_exit(0)`, which skips the SDK's own `willTerminate` flush. Kick a final - /// send and hold the main thread a bounded window so the in-flight URLSession - /// request — which runs off-main — has a chance to leave before the process - /// dies. This stays best-effort: the SDK's public `flush()` is fire-and-forget - /// with no completion handle, so we can't confirm delivery, only give it room. - /// No-ops (and costs nothing on quit) unless telemetry actually started and - /// the user granted consent, so keyless/disabled/undecided builds never block. - public func flushForQuit(timeout: TimeInterval = 0.6) { - guard started, isEnabled else { return } - Aptabase.shared.flush() - Thread.sleep(forTimeInterval: timeout) + /// Best-effort flush for the quit path. Aptabase's send queue is + /// in-memory only (no disk persistence), and the app hard-exits with + /// `_exit(0)`, which skips the SDK's own `willTerminate` flush. Reads the + /// main-actor gates here (the caller is on main) and returns the blocking + /// send-and-wait as a closure the fan-out can run on a worker thread, so + /// the hold no longer parks the main thread. `nil` when telemetry never + /// started or lacks consent — those quits pay nothing. + public func prepareQuitFlush(timeout: TimeInterval = 0.6) -> (@Sendable () -> Void)? { + guard started, isEnabled else { return nil } + return { + Aptabase.shared.flush() + Thread.sleep(forTimeInterval: timeout) + } } // MARK: - Consent diff --git a/Packages/OsaurusCore/Tests/Agent/SpawnConfigurationUISourceTests.swift b/Packages/OsaurusCore/Tests/Agent/SpawnConfigurationUISourceTests.swift index 2906874f11..9c8a2c7537 100644 --- a/Packages/OsaurusCore/Tests/Agent/SpawnConfigurationUISourceTests.swift +++ b/Packages/OsaurusCore/Tests/Agent/SpawnConfigurationUISourceTests.swift @@ -35,7 +35,7 @@ struct SpawnConfigurationUISourceTests { #expect(editor.contains(#"Text("Agent tools + read-only files""#)) #expect(editor.contains("cancellation-audited subset of their enabled tools")) #expect(editor.contains("host read-only file tools")) - #expect(editor.contains("modelPickerCache.items.chatModelCandidates")) + #expect(editor.contains("modelPickerCache.chatModelCandidates")) } @Test("open custom-agent editor refreshes shared handoff and concurrency state") diff --git a/Packages/OsaurusCore/Tests/Chat/ChatSessionQueuedSendTests.swift b/Packages/OsaurusCore/Tests/Chat/ChatSessionQueuedSendTests.swift index 5a4af23951..094b2edb99 100644 --- a/Packages/OsaurusCore/Tests/Chat/ChatSessionQueuedSendTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/ChatSessionQueuedSendTests.swift @@ -601,13 +601,20 @@ private actor DelayedCancellingBeforeDeltaChatEngine: ChatEngineProtocol { // MARK: - Local waitUntil (file-private to avoid colliding with other test files) +/// Main-actor isolated on purpose: a nonisolated helper hops off the main +/// actor after the predicate passes and hops back when returning to the +/// `@MainActor` test, and that suspension lets queued main-actor work run +/// between the satisfied predicate and the `#expect`s that follow it. Keeping +/// the helper on the main actor makes "predicate passed" and "assertions +/// observe the same state" one uninterrupted stretch. +@MainActor private func waitUntil( timeout: Duration, _ predicate: @MainActor @escaping () -> Bool ) async throws { let deadline = ContinuousClock.now + timeout while ContinuousClock.now < deadline { - if await predicate() { return } + if predicate() { return } try await Task.sleep(for: .milliseconds(20)) } throw NSError(domain: "ChatSessionQueuedSendTests", code: 2) diff --git a/Packages/OsaurusCore/Views/Agent/SpawnConfigurationEditor.swift b/Packages/OsaurusCore/Views/Agent/SpawnConfigurationEditor.swift index ee3e73da1c..9004eb44a0 100644 --- a/Packages/OsaurusCore/Views/Agent/SpawnConfigurationEditor.swift +++ b/Packages/OsaurusCore/Views/Agent/SpawnConfigurationEditor.swift @@ -514,7 +514,10 @@ struct SpawnConfigurationEditor: View { } private var modelCandidates: [ModelPickerItem] { - modelPickerCache.items.chatModelCandidates + // Stored projection on the cache (recomputed per rebuild), not + // `items.chatModelCandidates` — the filter's per-item string matching + // is too heavy to rerun on every body evaluation. + modelPickerCache.chatModelCandidates } /// Spawn persistence uses immutable provider UUIDs for remote rows. Local diff --git a/Packages/OsaurusCore/Views/Chat/FloatingInputCard.swift b/Packages/OsaurusCore/Views/Chat/FloatingInputCard.swift index b1526764ad..6214d64216 100644 --- a/Packages/OsaurusCore/Views/Chat/FloatingInputCard.swift +++ b/Packages/OsaurusCore/Views/Chat/FloatingInputCard.swift @@ -8847,7 +8847,14 @@ private struct FloatingVoiceButton: View { // `SpeechService.autoLoadIfNeeded` at launch) would otherwise // freeze the button and swallow the tap that needs to surface // either the system mic prompt or the denied alert. - let micAuthorized = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + // Read the service's published mirror rather than + // `AVCaptureDevice.authorizationStatus` directly: the direct call is + // a synchronous TCC daemon round-trip, and this getter runs on every + // body evaluation. The mirror is seeded at launch, re-checked on app + // activation, and updated by the request path, so it can only lag + // across an in-Settings toggle while the app stays frontmost — the + // same staleness every other voice surface already accepts. + let micAuthorized = speechService.microphonePermissionGranted return Group { if speechService.isLoadingModel && micAuthorized { // Original disabled-spinner state — only when mic is diff --git a/Packages/OsaurusCore/Views/Chat/NativeBlockViews.swift b/Packages/OsaurusCore/Views/Chat/NativeBlockViews.swift index ef41d0eb0c..24b5897a7d 100644 --- a/Packages/OsaurusCore/Views/Chat/NativeBlockViews.swift +++ b/Packages/OsaurusCore/Views/Chat/NativeBlockViews.swift @@ -594,8 +594,22 @@ final class NativeCodeBlockView: NSView { /// bounded cadence so colors appear progressively without paying the /// JavaScriptCore cost on every delta. private var streamingHighlightWork: DispatchWorkItem? + /// Set once the streaming code crosses `streamingHighlightMaxLength`. + /// Per-delta appends inherit the trailing character's attributes, and + /// the periodic full pass was what corrected them — so the first crossing + /// does one plain rebuild (everything after streams uncolored instead of + /// wearing the last token's color), and later passes are skipped. + private var streamingHighlightCapped = false private static let streamingHighlightInterval: TimeInterval = 0.4 + /// Mid-stream passes re-highlight the entire current code every interval, + /// synchronously on main, and JSC's tokenizer cost grows super-linearly — + /// past this size a single pass (or a GC pause inside it) can hit the + /// hang watchdog. Skip live coloring beyond it; the appended plain text + /// keeps streaming, and the finalize pass (under the shared 50k gate) + /// colorizes the finished block once. + private static let streamingHighlightMaxLength = 16_384 + // MARK: Init override init(frame: NSRect) { @@ -716,11 +730,16 @@ final class NativeCodeBlockView: NSView { /// retroactively, e.g. an unclosed block comment) and skips the shared /// highlight cache so streaming prefixes don't pollute it. private func scheduleStreamingHighlight(theme: any ThemeProtocol) { - guard streamingHighlightWork == nil else { return } + guard streamingHighlightWork == nil, !streamingHighlightCapped else { return } let work = DispatchWorkItem { [weak self] in guard let self else { return } self.streamingHighlightWork = nil guard self.lastIsStreaming, let cv = self.codeView else { return } + guard self.lastCode.utf16.count <= Self.streamingHighlightMaxLength else { + self.streamingHighlightCapped = true + self.applyStreamingText(to: cv, code: self.lastCode, theme: theme, fullRebuild: true) + return + } self.applyHighlighting( to: cv, code: self.lastCode, @@ -900,6 +919,7 @@ final class NativeCodeBlockView: NSView { streamingCursor = nil streamingHighlightWork?.cancel() streamingHighlightWork = nil + streamingHighlightCapped = false } /// New code landed — actively revealing is the opposite of "waiting". diff --git a/Packages/OsaurusCore/Views/Common/CodeBlockView.swift b/Packages/OsaurusCore/Views/Common/CodeBlockView.swift index faa05c682b..02f65fefb0 100644 --- a/Packages/OsaurusCore/Views/Common/CodeBlockView.swift +++ b/Packages/OsaurusCore/Views/Common/CodeBlockView.swift @@ -41,6 +41,20 @@ nonisolated(unsafe) private var currentHighlightrTheme: String = "atom-one-dark" private let defaultDarkHighlightTheme = "atom-one-dark" private let defaultLightHighlightTheme = "atom-one-light" +/// Kick the shared Highlightr's lazy initialization — which evaluates +/// highlight.js inside a fresh JSContext, tens-to-hundreds of ms plus any +/// JavaScriptCore GC pause — on a background thread, so the first code block +/// a table cell configures doesn't pay it on main. Safe to race real use: +/// the `let` global initializes exactly once and every touch is serialized +/// by `highlightrLock`. +func prewarmHighlightrOffMain() { + DispatchQueue.global(qos: .utility).async { + highlightrLock.lock() + _ = sharedHighlightr + highlightrLock.unlock() + } +} + /// Returns the available Highlightr theme names (cached after first call). nonisolated(unsafe) private var cachedAvailableThemes: [String]? func availableHighlightrThemes() -> [String] {