From 3126c34561d571e4e381b995384b1c0c00f02b0e Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 22:18:06 +0200 Subject: [PATCH] feat(scarfmon): chat + transport + sqlite measure points (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires ScarfMon measure points into the chat hot path on both targets, plus the underlying SSH transport and remote-SQLite backend. All callsites are surgical adds — no behavior change. Cost when ScarfMon is in `.signpostOnly` (default) is one os_signpost emit per call, elided by the runtime outside an Instruments session. In `.full` mode the same callsites also push samples into the in-memory ring buffer. Render counters (event): - mac.ChatView.body / ios.ChatView.body — full transcript pane re-evals - mac.RichMessageBubble.body / ios.MessageBubble.body — per-bubble re-evals Stream + session (event + interval): - mac.sendViaACP, mac.sendPrompt — user tap → first-byte - mac.acpEvent, mac.handleACPEvent — per-event delivery + handle cost - mac.startACPSession — session boot - ios.send, ios.startResuming — same shape on iOS - ios.acpEvent, ios.handleACPEvent — same per-event split on iOS Transport + SQLite (interval, with byte counts on rows): - ssh.streamScript (Citadel iOS) — SSH round-trip - ssh.run (SSHScriptRunner Mac) — SSH round-trip - sqlite.query, sqlite.queryBatch — Remote SQLite per-call - sqlite.query.rows — row count + stdout bytes per query Disk I/O (interval): - diskIO.loadConfig — config.yaml read + parse - diskIO.loadCronJobs — cron jobs.json decode Body counters use the `let _: Void = ScarfMon.event(...)` pattern at the top of `body` — works inside `@ViewBuilder` and fires on every re-eval, which is exactly the signal we want. To use: Mac: Settings → Advanced → Performance Diagnostics → Full iOS: Settings → Diagnostics → Performance → Full Both panels auto-aggregate by (category, name), surface top 20 by p95, and offer Copy as JSON for sharing in feedback threads. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backends/RemoteSQLiteBackend.swift | 44 ++++++++++++------- .../ScarfCore/Transport/SSHScriptRunner.swift | 20 +++++---- .../ScarfIOS/CitadelServerTransport.swift | 6 +++ scarf/Scarf iOS/Chat/ChatView.swift | 29 +++++++++++- .../Core/Services/HermesFileService.swift | 22 ++++++---- .../Chat/ViewModels/ChatViewModel.swift | 11 ++++- .../scarf/Features/Chat/Views/ChatView.swift | 6 +++ .../Chat/Views/RichMessageBubble.swift | 5 +++ 8 files changed, 104 insertions(+), 39 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift index 684eaaa..39b46d6 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift @@ -149,27 +149,37 @@ public actor RemoteSQLiteBackend: HermesQueryBackend { // MARK: - Queries public func query(_ sql: String, params: [SQLValue]) async throws -> [Row] { - guard isOpen else { throw BackendError.notOpen } - let inlined = SQLValueInliner.inline(sql, params: params) - let dbPath = context.paths.stateDB - let script = """ - sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__' - \(inlined) - __SCARF_SQL__ - """ - let result: ProcessResult - do { - result = try await transport.streamScript(script, timeout: queryTimeout) - } catch { - throw BackendError.transport(error.localizedDescription) + try await ScarfMon.measureAsync(.sqlite, "query") { + guard isOpen else { throw BackendError.notOpen } + let inlined = SQLValueInliner.inline(sql, params: params) + let dbPath = context.paths.stateDB + let script = """ + sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__' + \(inlined) + __SCARF_SQL__ + """ + let result: ProcessResult + do { + result = try await transport.streamScript(script, timeout: queryTimeout) + } catch { + throw BackendError.transport(error.localizedDescription) + } + if result.exitCode != 0 { + throw BackendError.sqlite(exitCode: result.exitCode, stderr: result.stderrString) + } + let rows = try parseSingleResultSet(result.stdoutString) + ScarfMon.event(.sqlite, "query.rows", count: rows.count, bytes: result.stdout.count) + return rows } - if result.exitCode != 0 { - throw BackendError.sqlite(exitCode: result.exitCode, stderr: result.stderrString) - } - return try parseSingleResultSet(result.stdoutString) } public func queryBatch(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] { + try await ScarfMon.measureAsync(.sqlite, "queryBatch") { + try await _queryBatchImpl(statements) + } + } + + private func _queryBatchImpl(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] { guard isOpen else { throw BackendError.notOpen } if statements.isEmpty { return [] } // Build one sqlite3 invocation with marker SELECTs separating diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift index 9f101d8..b1cd0ff 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift @@ -46,16 +46,18 @@ public enum SSHScriptRunner { /// cross-platform we return a connect failure on non-macOS so /// the file compiles everywhere. public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome { - #if os(macOS) - switch context.kind { - case .local: - return await runLocally(script: script, timeout: timeout) - case .ssh(let config): - return await runOverSSH(script: script, config: config, timeout: timeout) + await ScarfMon.measureAsync(.transport, "ssh.run") { + #if os(macOS) + switch context.kind { + case .local: + return await runLocally(script: script, timeout: timeout) + case .ssh(let config): + return await runOverSSH(script: script, config: config, timeout: timeout) + } + #else + return .connectFailure("SSHScriptRunner is only available on macOS") + #endif } - #else - return .connectFailure("SSHScriptRunner is only available on macOS") - #endif } // MARK: - SSH path diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift index eb17acb..850303d 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift @@ -179,6 +179,12 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable /// untouched — same correctness guarantee as `SSHScriptRunner`'s /// stdin-pipe approach. public func streamScript(_ script: String, timeout: TimeInterval) async throws -> ProcessResult { + try await ScarfMon.measureAsync(.transport, "ssh.streamScript") { + try await _streamScriptImpl(script, timeout: timeout) + } + } + + private func _streamScriptImpl(_ script: String, timeout: TimeInterval) async throws -> ProcessResult { let scriptBytes = Data(script.utf8) let b64 = scriptBytes.base64EncodedString() // Prepend the same PATH guard that `asyncRunProcess` uses so diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index 938b52c..7aafc7c 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -66,7 +66,12 @@ struct ChatView: View { )! var body: some View { - VStack(spacing: 0) { + // ScarfMon body-evaluation counter. Re-render churn during + // streaming is one of the load-bearing perf signals; rendering + // here costs ~one signpost emit + ring-buffer append (off the + // hot path otherwise). + let _: Void = ScarfMon.event(.chatRender, "ios.ChatView.body") + return VStack(spacing: 0) { connectionBanner errorBanner projectContextBar @@ -1254,6 +1259,12 @@ final class ChatController { /// assistant reply streams back as ACP notifications handled by /// the event task. func send() async { + await ScarfMon.measureAsync(.chatStream, "ios.send") { + await _sendImpl() + } + } + + private func _sendImpl() async { guard state == .ready, let client else { return } let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) // v0.12+ allows image-only sends — vision models accept "describe @@ -1358,7 +1369,10 @@ final class ChatController { let stream = await client.events for await event in stream { guard !Task.isCancelled else { break } - self?.vm.handleACPEvent(event) + ScarfMon.event(.chatStream, "ios.acpEvent", count: 1) + ScarfMon.measure(.chatStream, "ios.handleACPEvent") { + self?.vm.handleACPEvent(event) + } } // Stream ended — if we weren't explicitly cancelled the // channel died (EOF on stdin/out, write to dead pipe, @@ -1788,6 +1802,12 @@ final class ChatController { /// to `session/load` if the remote doesn't support `session/resume` /// (Hermes < 0.9.x). func startResuming(sessionID: String) async { + await ScarfMon.measureAsync(.sessionLoad, "ios.startResuming") { + await _startResumingImpl(sessionID: sessionID) + } + } + + private func _startResumingImpl(sessionID: String) async { guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return } await stop() vm.reset() @@ -1952,6 +1972,11 @@ private struct MessageBubble: View, Equatable { } var body: some View { + // Per-bubble render counter. The streaming bubble + // (`message.id == 0`) re-renders on every chunk; tracking the + // count here is what tells us if a slow chat is bottlenecked + // on body re-eval vs. event-loop delivery. + let _: Void = ScarfMon.event(.chatRender, "ios.MessageBubble.body") if message.isToolResult { ToolResultRow(message: message) } else { diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 51cb8a1..246daf0 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -17,8 +17,10 @@ struct HermesFileService: Sendable { // MARK: - Config nonisolated func loadConfig() -> HermesConfig { - guard let content = readFile(context.paths.configYAML) else { return .empty } - return parseConfig(content) + ScarfMon.measure(.diskIO, "loadConfig") { + guard let content = readFile(context.paths.configYAML) else { return .empty } + return parseConfig(content) + } } /// Error-surfacing config load. Used by Dashboard to show the user a @@ -480,13 +482,15 @@ struct HermesFileService: Sendable { // MARK: - Cron nonisolated func loadCronJobs() -> [HermesCronJob] { - guard let data = readFileData(context.paths.cronJobsJSON) else { return [] } - do { - let file = try JSONDecoder().decode(CronJobsFile.self, from: data) - return file.jobs - } catch { - print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)") - return [] + ScarfMon.measure(.diskIO, "loadCronJobs") { + guard let data = readFileData(context.paths.cronJobsJSON) else { return [] } + do { + let file = try JSONDecoder().decode(CronJobsFile.self, from: data) + return file.jobs + } catch { + print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)") + return [] + } } } diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index e48bc7f..e01857f 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -395,6 +395,7 @@ final class ChatViewModel { } private func sendViaACP(client: ACPClient, text: String, images: [ChatImageAttachment] = []) { + ScarfMon.event(.chatStream, "mac.sendViaACP", count: 1, bytes: text.utf8.count) guard let sessionId = richChatViewModel.sessionId else { clearACPErrorState() acpError = "No session ID — cannot send" @@ -434,7 +435,9 @@ final class ChatViewModel { } acpPromptTask = Task { @MainActor in do { - let result = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images) + let result = try await ScarfMon.measureAsync(.chatStream, "mac.sendPrompt") { + try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images) + } acpStatus = "Ready" richChatViewModel.handleACPEvent( .promptComplete(sessionId: sessionId, response: result) @@ -475,6 +478,7 @@ final class ChatViewModel { // MARK: - ACP Session Management private func startACPSession(resume sessionId: String?, projectPath: String? = nil) { + ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1) stopACP() clearACPErrorState() @@ -655,7 +659,10 @@ final class ChatViewModel { let eventStream = await client.events for await event in eventStream { guard !Task.isCancelled else { break } - self?.richChatViewModel.handleACPEvent(event) + ScarfMon.event(.chatStream, "mac.acpEvent", count: 1) + ScarfMon.measure(.chatStream, "mac.handleACPEvent") { + self?.richChatViewModel.handleACPEvent(event) + } self?.acpStatus = await client.statusMessage } // Stream ended — if we weren't cancelled, the connection died diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 9e26b79..aa9b28b 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -17,6 +17,12 @@ struct ChatView: View { private var showInspector: Bool = true var body: some View { + // ScarfMon body-evaluation counter — tracks how many times + // SwiftUI re-evaluates this view per second during streaming. + // High counts here usually mean state is fanning out further + // than necessary; pair with `mac.RichMessageBubble.body` to + // see whether the churn lives in the parent or the bubbles. + let _: Void = ScarfMon.event(.chatRender, "mac.ChatView.body") @Bindable var vm = viewModel @Bindable var coord = coordinator VStack(spacing: 0) { diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index e924217..7351cd3 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -58,6 +58,11 @@ struct RichMessageBubble: View, Equatable { } var body: some View { + // Per-bubble render counter. The streaming bubble re-renders + // per token; cross-reference with `mac.ChatView.body` and + // `chatStream.handleACPEvent` to see whether streaming churn + // lives in the parent, the bubble, or the event handler. + let _: Void = ScarfMon.event(.chatRender, "mac.RichMessageBubble.body") if message.isUser { userBubble } else if message.isAssistant {