diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/Resources/ArtifactParity/codex-adversarial.jsonl b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/Resources/ArtifactParity/codex-adversarial.jsonl index 257ef795d12d..a282e4ae60f2 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/Resources/ArtifactParity/codex-adversarial.jsonl +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/Resources/ArtifactParity/codex-adversarial.jsonl @@ -16,4 +16,3 @@ {"timestamp":"2026-07-13T12:00:00.000Z","type":"event_msg","payload":{"type":"agent_message","message":"Event-only prose /tmp/parity/CODEX-event-agent.png","phase":"commentary"}} {"timestamp":"2026-07-13T12:00:00.000Z","type":"event_msg","payload":{"type":"exec_command_begin","call_id":"event-exec","command":"cat /tmp/parity/CODEX-event-command.txt"}} {"timestamp":"2026-07-13T12:00:00.000Z","type":"event_msg","payload":{"type":"exec_command_end","call_id":"event-exec","stdout":"saved /tmp/parity/CODEX-event-output.txt","stderr":"","exit_code":0}} - diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift index 6a4b03e1b113..2c9af8da3a56 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift @@ -28,6 +28,7 @@ public protocol ControlCommandContext: ControlSurfaceContext, ControlSystemContext, ControlProjectContext, + ControlPerformanceContext, ControlDebugContext, ControlSidebarContext, ControlBrowserPanelContext diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift index 62ca2597c327..e527407acc82 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -81,6 +81,7 @@ public final class ControlCommandCoordinator { if let result = handleSurface(request) { return result } if let result = handleSystem(request) { return result } if let result = handleProject(request) { return result } + if let result = handlePerformance(request) { return result } if let result = handleDebug(request) { return result } // The v2 browser.* domain stays app-side: PR 5778 moved its // JS-evaluating methods onto the socket-worker lane (nonisolated diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift index b15c2196cdc6..10fc90624ebf 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift @@ -1,18 +1,25 @@ internal import Foundation -/// The debug/test-only domain (`debug.*` main-actor methods), lifted byte-faithfully -/// from the former `TerminalController.v2Debug*` bodies; each payload is built directly -/// as a ``JSONValue``, so the encoded wire bytes match. DEBUG-gated end to end; release -/// builds fall through to the same `method_not_found` behavior as the former -/// compiled-out cases. The worker-lane `debug.sidebar.simulate_drag` and the shared -/// `debug.terminals` stay app-side/surface-domain and are NOT handled here. +/// The debug/test-only domain (`debug.*` main-actor methods), lifted +/// byte-faithfully from the former `TerminalController.v2Debug*` bodies. Each +/// payload is built directly as a ``JSONValue``; the encoded wire bytes match. +/// +/// This domain is DEBUG-gated end to end; release builds fall through to the +/// same `method_not_found` behavior as the former compiled-out cases. The worker-lane +/// `debug.sidebar.simulate_drag` and the shared `debug.terminals` stay +/// app-side/surface-domain and are NOT handled here. +/// /// This file carries the dispatch plus the session-snapshot, shortcut, input, -/// text-box, and command-palette methods; the rest live in `+Debug2.swift` (500-line budget). +/// text-box, and command-palette methods; the rest live in `+Debug2.swift` +/// (500-line budget). extension ControlCommandCoordinator { - /// Runs one decoded request (`request` = the decoded envelope) if it belongs to - /// the debug domain, returning the typed result; returns `nil` otherwise — including - /// in release builds, where the domain does not exist — so the caller can fall + /// Runs one decoded request if it belongs to the debug domain, returning + /// the typed result; returns `nil` otherwise so the caller can fall /// through. The integrator calls this from the core `handle`. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a debug method (or in a + /// release build, where the domain does not exist). func handleDebug(_ request: ControlRequest) -> ControlCallResult? { #if DEBUG switch request.method { @@ -22,6 +29,10 @@ extension ControlCommandCoordinator { return debugSessionSnapshotBenchmark(request.params) case "debug.session_snapshot_seed_scrollback": return debugSessionSnapshotSeedScrollback(request.params) + case "debug.process_metrics.read": + return debugProcessMetricsRead() + case "debug.process_metrics.reset": + return debugProcessMetricsReset() case "debug.shortcut.set": return debugShortcutSet(request.params) case "debug.shortcut.simulate": @@ -106,394 +117,3 @@ extension ControlCommandCoordinator { #endif } } - -#if DEBUG -extension ControlCommandCoordinator { - /// The deterministic v1-style response used when the seam is unwired (`context == - /// nil`). Unreachable in practice — the composition owner wires the context during - /// its own init — but keeps the v1-forwarding bodies total: it fails both the - /// `== "OK"` and `hasPrefix("OK ")` checks, surfacing as the legacy `internal_error` path. - static let debugContextUnavailableResponse = "ERROR: control context unavailable" - - /// The debug-domain view of the seam. Once the integrator adds - /// ``ControlDebugContext`` to the ``ControlCommandContext`` umbrella this - /// cast is statically guaranteed (and may be simplified to `context`); - /// until then it lets the domain build standalone without touching the - /// integrator-owned umbrella file. - var debugContext: (any ControlDebugContext)? { - context as? any ControlDebugContext - } - - // MARK: - debug.canvas.command_scroll_hint - - /// `debug.canvas.command_scroll_hint` — show the canvas scroll hint toast - /// through the same canvas action path used by the Debug menu. - func debugCanvasCommandScrollHint(_ params: [String: JSONValue]) -> ControlCallResult { - let routing = routingSelectors(params) - let resolution = debugContext?.controlDebugShowCanvasCommandScrollHint(routing: routing) - ?? .tabManagerUnavailable - return canvasActionResult(resolution) - } - - // MARK: - debug.session_snapshot_benchmark - - /// `debug.session_snapshot_benchmark` — run the DEBUG snapshot benchmark. - func debugSessionSnapshotBenchmark(_ params: [String: JSONValue]) -> ControlCallResult { - let includeScrollback = bool(params, "include_scrollback") - ?? bool(params, "scrollback") - ?? false - let persist = bool(params, "persist") ?? true - guard let payload = debugContext?.controlDebugSessionSnapshotBenchmark( - includeScrollback: includeScrollback, - persist: persist - ) else { - return .err(code: "unavailable", message: "AppDelegate not available", data: nil) - } - return .ok(payload) - } - - /// `debug.session_snapshot_seed_scrollback` — seed synthetic scrollback. - func debugSessionSnapshotSeedScrollback(_ params: [String: JSONValue]) -> ControlCallResult { - let charactersPerTerminal = int(params, "characters_per_terminal") - ?? int(params, "chars_per_terminal") - ?? 0 - guard let payload = debugContext?.controlDebugSessionSnapshotSeedScrollback( - charactersPerTerminal: charactersPerTerminal - ) else { - return .err(code: "unavailable", message: "AppDelegate not available", data: nil) - } - return .ok(payload) - } - - // MARK: - debug.shortcut.* - - /// `debug.shortcut.set` — bind a shortcut via the shared v1 body. - func debugShortcutSet(_ params: [String: JSONValue]) -> ControlCallResult { - guard let name = string(params, "name"), - let combo = string(params, "combo") else { - return .err(code: "invalid_params", message: "Missing name/combo", data: nil) - } - let resp = debugContext?.controlDebugSetShortcut(arguments: "\(name) \(combo)") - ?? Self.debugContextUnavailableResponse - return resp == "OK" - ? .ok(.object([:])) - : .err(code: "internal_error", message: resp, data: nil) - } - - /// `debug.shortcut.simulate` — simulate a shortcut via the shared v1 body. - func debugShortcutSimulate(_ params: [String: JSONValue]) -> ControlCallResult { - guard let combo = string(params, "combo") else { - return .err(code: "invalid_params", message: "Missing combo", data: nil) - } - let resp = debugContext?.controlDebugSimulateShortcut(combo: combo) - ?? Self.debugContextUnavailableResponse - return resp == "OK" - ? .ok(.object([:])) - : .err(code: "internal_error", message: resp, data: nil) - } - - // MARK: - debug.type - - /// `debug.type` — insert text at the key window's first responder. - func debugType(_ params: [String: JSONValue]) -> ControlCallResult { - // Legacy `params["text"] as? String`: raw, untrimmed; empty allowed. - guard let text = rawString(params, "text") else { - return .err(code: "invalid_params", message: "Missing text", data: nil) - } - switch debugContext?.controlDebugTypeText(text) { - case .noWindow: - return .err(code: "not_found", message: "No window", data: nil) - case .noFirstResponder: - return .err(code: "not_found", message: "No first responder", data: nil) - case .inserted: - return .ok(.object([:])) - case nil: - // The legacy body's initial (unreachable) value, kept for the - // equally unreachable unwired-context case. - return .err(code: "internal_error", message: "No window", data: nil) - } - } - - // MARK: - debug.pro_welcome_checklist.show — show the Pro welcome checklist - - func debugShowProWelcomeChecklist() -> ControlCallResult { - guard let debugContext else { - return .err(code: "unavailable", message: "Control context unavailable", data: nil) - } - debugContext.controlDebugShowProWelcomeChecklist() - return .ok(.object(["shown": .bool(true)])) - } - - // MARK: - debug.textbox.* - - /// `debug.textbox.inline_fixture` — install the inline text-box fixture. - func debugTextBoxInlineFixture(_ params: [String: JSONValue]) -> ControlCallResult { - guard debugContext?.controlDebugTabManagerAvailable() == true else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - let rawPathValue = rawString(params, "path") - let rawPath = rawPathValue?.trimmingCharacters(in: .whitespacesAndNewlines) - if let rawPathValue, rawPathValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return .err(code: "invalid_params", message: "path cannot be empty", data: nil) - } - let hasAttachment = rawPath?.isEmpty == false - let beforeText = rawString(params, "before_text") ?? (hasAttachment ? "hello " : "") - let afterText = rawString(params, "after_text") ?? (hasAttachment ? "world" : "") - let rawSurfaceID = rawString(params, "surface_id") - let target = rawSurfaceID?.trimmingCharacters(in: .whitespacesAndNewlines) - if let rawSurfaceID, - rawSurfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return .err(code: "invalid_params", message: "surface_id cannot be empty", data: nil) - } - - guard let snapshot = debugContext?.controlDebugTextBoxInlineFixture( - target: target, - path: rawPath, - beforeText: beforeText, - afterText: afterText - ) else { - return .err(code: "not_found", message: "Terminal panel not found", data: nil) - } - return .ok(.object([ - "surface_id": .string(snapshot.surfaceID.uuidString), - "surface_ref": ref(.surface, snapshot.surfaceID), - "path": .string(snapshot.path), - "text_box_active": .bool(snapshot.isTextBoxActive), - "has_text_view": .bool(snapshot.hasTextView), - "text_view_has_window": .bool(snapshot.textViewHasWindow), - "text_view_matches_panel_window": .bool(snapshot.textViewMatchesPanelWindow), - "panel_text": .string(snapshot.panelText), - "panel_attachment_count": .int(Int64(snapshot.panelAttachmentCount)), - "text_view_text": .string(snapshot.textViewText), - "text_view_attachment_count": .int(Int64(snapshot.textViewAttachmentCount)), - ])) - } - - /// `debug.textbox.interact` — drive one scripted text-box interaction. - func debugTextBoxInteract(_ params: [String: JSONValue]) -> ControlCallResult { - guard debugContext?.controlDebugTabManagerAvailable() == true else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - // Legacy re-trimmed and re-checked `v2String`'s output; `string` already - // yields a trimmed non-empty value, so the acceptance is identical. - guard let action = string(params, "action") else { - return .err(code: "invalid_params", message: "Missing action", data: nil) - } - let rawSurfaceID = rawString(params, "surface_id") - let target = rawSurfaceID?.trimmingCharacters(in: .whitespacesAndNewlines) - if let rawSurfaceID, - rawSurfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return .err(code: "invalid_params", message: "surface_id cannot be empty", data: nil) - } - - guard let interaction = debugContext?.controlDebugTextBoxInteract(target: target, action: action) else { - return .err(code: "not_found", message: "Terminal text box not found", data: nil) - } - return .ok(.object([ - "surface_id": .string(interaction.surfaceID.uuidString), - "surface_ref": ref(.surface, interaction.surfaceID), - "action": .string(action), - "state": interaction.state, - ])) - } - - // MARK: - debug.app.activate - - /// `debug.app.activate` — activate the app via the shared v1 body. - func debugActivateApp() -> ControlCallResult { - let resp = debugContext?.controlDebugActivateApp() ?? Self.debugContextUnavailableResponse - return resp == "OK" - ? .ok(.object([:])) - : .err(code: "internal_error", message: resp, data: nil) - } - - /// `debug.workspace_todo.checklist_add_field` — request the selected workspace's checklist add field. - func debugWorkspaceTodoChecklistAddField() -> ControlCallResult { - guard let debugContext else { return .err(code: "unavailable", message: "Control debug context unavailable", data: nil) } - guard let workspaceID = debugContext.controlDebugRequestWorkspaceTodoChecklistAddField() else { return .err(code: "not_found", message: "No selected workspace", data: nil) } - return .ok(.object(["workspace_id": .string(workspaceID.uuidString), "workspace_ref": ref(.workspace, workspaceID), "requested": .bool(true)])) - } - - // MARK: - debug.command_palette.* (event posts) - - /// The shared body of the four palette-notification commands (`toggle`, - /// `rename_tab.open`, `rename_input.interact`, - /// `rename_input.delete_backward`): identical param shape, identical - /// `not_found` payload, differing only in the posted notification. - func debugCommandPaletteEvent( - _ event: ControlDebugCommandPaletteEvent, - _ params: [String: JSONValue] - ) -> ControlCallResult { - let requestedWindowID = uuid(params, "window_id") - let posted = debugContext?.controlDebugPostCommandPaletteEvent(event, windowID: requestedWindowID) ?? false - if let requestedWindowID, !posted { - return .err(code: "not_found", message: "Window not found", data: .object([ - "window_id": .string(requestedWindowID.uuidString), - "window_ref": ref(.window, requestedWindowID), - ])) - } - return .ok(.object([:])) - } - - // MARK: - debug.command_palette.* (reads) - - /// `debug.command_palette.visible` — palette visibility in a window. - func debugCommandPaletteVisible(_ params: [String: JSONValue]) -> ControlCallResult { - guard let windowID = uuid(params, "window_id") else { - return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) - } - let visible = debugContext?.controlDebugCommandPaletteVisible(windowID: windowID) ?? false - return .ok(.object([ - "window_id": .string(windowID.uuidString), - "window_ref": ref(.window, windowID), - "visible": .bool(visible), - ])) - } - - /// `debug.command_palette.selection` — palette visibility + selected row. - func debugCommandPaletteSelection(_ params: [String: JSONValue]) -> ControlCallResult { - guard let windowID = uuid(params, "window_id") else { - return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) - } - let visible = debugContext?.controlDebugCommandPaletteVisible(windowID: windowID) ?? false - let selectedIndex = debugContext?.controlDebugCommandPaletteSelectionIndex(windowID: windowID) ?? 0 - return .ok(.object([ - "window_id": .string(windowID.uuidString), - "window_ref": ref(.window, windowID), - "visible": .bool(visible), - "selected_index": .int(Int64(max(0, selectedIndex))), - ])) - } - - /// `debug.command_palette.results` — palette query/mode/result rows. - func debugCommandPaletteResults(_ params: [String: JSONValue]) -> ControlCallResult { - guard let windowID = uuid(params, "window_id") else { - return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) - } - // Legacy `params["limit"] as? Int` (NSNumber exact-integer semantics). - let requestedLimit = legacyExactInt(params["limit"]) - let limit = max(1, min(100, requestedLimit ?? 20)) - - let visible = debugContext?.controlDebugCommandPaletteVisible(windowID: windowID) ?? false - let selectedIndex = debugContext?.controlDebugCommandPaletteSelectionIndex(windowID: windowID) ?? 0 - let snapshot = debugContext?.controlDebugCommandPaletteSnapshot(windowID: windowID) ?? .empty - - let rows: [JSONValue] = Array(snapshot.results.prefix(limit)).map { row in - .object([ - "command_id": .string(row.commandID), - "title": .string(row.title), - "shortcut_hint": orNull(row.shortcutHint), - "trailing_label": orNull(row.trailingLabel), - "score": .int(Int64(row.score)), - ]) - } - - return .ok(.object([ - "window_id": .string(windowID.uuidString), - "window_ref": ref(.window, windowID), - "visible": .bool(visible), - "selected_index": .int(Int64(max(0, selectedIndex))), - "query": .string(snapshot.query), - "mode": .string(snapshot.mode), - "results": .array(rows), - ])) - } - - /// `debug.command_palette.rename_input.selection` — field-editor selection. - func debugCommandPaletteRenameInputSelection(_ params: [String: JSONValue]) -> ControlCallResult { - guard let windowID = uuid(params, "window_id") else { - return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) - } - // An unwired context reads as `inactive` — the legacy body's initial - // `focused: false` payload, which survived when nothing ran. - let resolution = debugContext?.controlDebugCommandPaletteRenameInputSelection(windowID: windowID) - ?? .inactive - switch resolution { - case .windowNotFound: - return .err(code: "not_found", message: "Window not found", data: .object([ - "window_id": .string(windowID.uuidString), - "window_ref": ref(.window, windowID), - ])) - case .inactive: - return .ok(.object([ - "window_id": .string(windowID.uuidString), - "window_ref": ref(.window, windowID), - "focused": .bool(false), - "selection_location": .int(0), - "selection_length": .int(0), - "text_length": .int(0), - ])) - case .active(let location, let length, let textLength): - return .ok(.object([ - "window_id": .string(windowID.uuidString), - "window_ref": ref(.window, windowID), - "focused": .bool(true), - "selection_location": .int(Int64(max(0, location))), - "selection_length": .int(Int64(max(0, length))), - "text_length": .int(Int64(max(0, textLength))), - ])) - } - } - - /// `debug.command_palette.rename_input.select_all` — read (and optionally - /// write) the select-all-on-focus setting. - func debugCommandPaletteRenameInputSelectAll(_ params: [String: JSONValue]) -> ControlCallResult { - var newValue: Bool? - if let rawEnabled = params["enabled"] { - // Legacy `rawEnabled as? Bool` on the bridged NSNumber: booleans - // pass, 0/1 numbers pass, everything else errors. - guard let enabled = legacyExactBool(rawEnabled) else { - return .err( - code: "invalid_params", - message: "enabled must be a bool", - data: .object(["enabled": rawEnabled]) - ) - } - newValue = enabled - } - let enabled = debugContext?.controlDebugCommandPaletteRenameSelectAll(updating: newValue) ?? false - return .ok(.object([ - "enabled": .bool(enabled) - ])) - } - - // MARK: - Legacy NSNumber-cast twins - - /// The typed twin of the legacy `value as? Int` on a JSON-bridged - /// `NSNumber`: exact integers pass, booleans bridge to `0`/`1`, fractional - /// or non-numeric values fail. (Distinct from `strictIntValue`, which - /// rejects booleans and accepts numeric strings.) - func legacyExactInt(_ value: JSONValue?) -> Int? { - switch value { - case .int(let v): - return Int(exactly: v) - case .double(let v): - return Int(exactly: v) - case .bool(let v): - return v ? 1 : 0 - default: - return nil - } - } - - /// The typed twin of the legacy `value as? Bool` on a JSON-bridged - /// `NSNumber`: booleans pass, and exactly-`0`/`1` numbers bridge to - /// `false`/`true` (Foundation's `Bool(exactly:)` semantics). - func legacyExactBool(_ value: JSONValue) -> Bool? { - switch value { - case .bool(let v): - return v - case .int(0): - return false - case .int(1): - return true - case .double(let v) where v == 0: - return false - case .double(let v) where v == 1: - return true - default: - return nil - } - } -} -#endif diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugInteraction.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugInteraction.swift new file mode 100644 index 000000000000..7687c1aa1074 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugInteraction.swift @@ -0,0 +1,417 @@ +internal import Foundation + +#if DEBUG +extension ControlCommandCoordinator { + /// The deterministic v1-style response used when the seam is unwired + /// (`context == nil`). Unreachable in practice — the composition owner + /// wires the context during its own init — but keeps the v1-forwarding + /// bodies total: it fails both the `== "OK"` and `hasPrefix("OK ")` + /// checks, surfacing as the legacy `internal_error` path. + static let debugContextUnavailableResponse = "ERROR: control context unavailable" + + /// The debug-domain view of the seam. Once the integrator adds + /// ``ControlDebugContext`` to the ``ControlCommandContext`` umbrella this + /// cast is statically guaranteed (and may be simplified to `context`); + /// until then it lets the domain build standalone without touching the + /// integrator-owned umbrella file. + var debugContext: (any ControlDebugContext)? { + context as? any ControlDebugContext + } + + // MARK: - debug.canvas.command_scroll_hint + + /// `debug.canvas.command_scroll_hint` — show the canvas scroll hint toast + /// through the same canvas action path used by the Debug menu. + func debugCanvasCommandScrollHint(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + let resolution = debugContext?.controlDebugShowCanvasCommandScrollHint(routing: routing) + ?? .tabManagerUnavailable + return canvasActionResult(resolution) + } + + // MARK: - debug.session_snapshot_benchmark + + /// `debug.session_snapshot_benchmark` — run the DEBUG snapshot benchmark. + func debugSessionSnapshotBenchmark(_ params: [String: JSONValue]) -> ControlCallResult { + let includeScrollback = bool(params, "include_scrollback") + ?? bool(params, "scrollback") + ?? false + let persist = bool(params, "persist") ?? true + guard let payload = debugContext?.controlDebugSessionSnapshotBenchmark( + includeScrollback: includeScrollback, + persist: persist + ) else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + return .ok(payload) + } + + /// `debug.session_snapshot_seed_scrollback` — seed synthetic scrollback. + func debugSessionSnapshotSeedScrollback(_ params: [String: JSONValue]) -> ControlCallResult { + let charactersPerTerminal = int(params, "characters_per_terminal") + ?? int(params, "chars_per_terminal") + ?? 0 + guard let payload = debugContext?.controlDebugSessionSnapshotSeedScrollback( + charactersPerTerminal: charactersPerTerminal + ) else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + return .ok(payload) + } + + // MARK: - debug.process_metrics.* + + func debugProcessMetricsRead() -> ControlCallResult { + guard let payload = debugContext?.controlDebugReadProcessPerformanceMetrics() else { + return .err(code: "unavailable", message: "Process metrics unavailable", data: nil) + } + return .ok(payload) + } + + func debugProcessMetricsReset() -> ControlCallResult { + guard let payload = debugContext?.controlDebugResetProcessPerformanceMetrics() else { + return .err(code: "unavailable", message: "Process metrics unavailable", data: nil) + } + return .ok(payload) + } + + // MARK: - debug.shortcut.* + + /// `debug.shortcut.set` — bind a shortcut via the shared v1 body. + func debugShortcutSet(_ params: [String: JSONValue]) -> ControlCallResult { + guard let name = string(params, "name"), + let combo = string(params, "combo") else { + return .err(code: "invalid_params", message: "Missing name/combo", data: nil) + } + let resp = debugContext?.controlDebugSetShortcut(arguments: "\(name) \(combo)") + ?? Self.debugContextUnavailableResponse + return resp == "OK" + ? .ok(.object([:])) + : .err(code: "internal_error", message: resp, data: nil) + } + + /// `debug.shortcut.simulate` — simulate a shortcut via the shared v1 body. + func debugShortcutSimulate(_ params: [String: JSONValue]) -> ControlCallResult { + guard let combo = string(params, "combo") else { + return .err(code: "invalid_params", message: "Missing combo", data: nil) + } + let resp = debugContext?.controlDebugSimulateShortcut(combo: combo) + ?? Self.debugContextUnavailableResponse + return resp == "OK" + ? .ok(.object([:])) + : .err(code: "internal_error", message: resp, data: nil) + } + + // MARK: - debug.type + + /// `debug.type` — insert text at the key window's first responder. + func debugType(_ params: [String: JSONValue]) -> ControlCallResult { + // Legacy `params["text"] as? String`: raw, untrimmed; empty allowed. + guard let text = rawString(params, "text") else { + return .err(code: "invalid_params", message: "Missing text", data: nil) + } + switch debugContext?.controlDebugTypeText(text) { + case .noWindow: + return .err(code: "not_found", message: "No window", data: nil) + case .noFirstResponder: + return .err(code: "not_found", message: "No first responder", data: nil) + case .inserted: + return .ok(.object([:])) + case nil: + // The legacy body's initial (unreachable) value, kept for the + // equally unreachable unwired-context case. + return .err(code: "internal_error", message: "No window", data: nil) + } + } + + // MARK: - debug.pro_welcome_checklist.show — show the Pro welcome checklist + + func debugShowProWelcomeChecklist() -> ControlCallResult { + guard let debugContext else { + return .err(code: "unavailable", message: "Control context unavailable", data: nil) + } + debugContext.controlDebugShowProWelcomeChecklist() + return .ok(.object(["shown": .bool(true)])) + } + + // MARK: - debug.textbox.* + + /// `debug.textbox.inline_fixture` — install the inline text-box fixture. + func debugTextBoxInlineFixture(_ params: [String: JSONValue]) -> ControlCallResult { + guard debugContext?.controlDebugTabManagerAvailable() == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let rawPathValue = rawString(params, "path") + let rawPath = rawPathValue?.trimmingCharacters(in: .whitespacesAndNewlines) + if let rawPathValue, rawPathValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .err(code: "invalid_params", message: "path cannot be empty", data: nil) + } + let hasAttachment = rawPath?.isEmpty == false + let beforeText = rawString(params, "before_text") ?? (hasAttachment ? "hello " : "") + let afterText = rawString(params, "after_text") ?? (hasAttachment ? "world" : "") + let rawSurfaceID = rawString(params, "surface_id") + let target = rawSurfaceID?.trimmingCharacters(in: .whitespacesAndNewlines) + if let rawSurfaceID, + rawSurfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .err(code: "invalid_params", message: "surface_id cannot be empty", data: nil) + } + + guard let snapshot = debugContext?.controlDebugTextBoxInlineFixture( + target: target, + path: rawPath, + beforeText: beforeText, + afterText: afterText + ) else { + return .err(code: "not_found", message: "Terminal panel not found", data: nil) + } + return .ok(.object([ + "surface_id": .string(snapshot.surfaceID.uuidString), + "surface_ref": ref(.surface, snapshot.surfaceID), + "path": .string(snapshot.path), + "text_box_active": .bool(snapshot.isTextBoxActive), + "has_text_view": .bool(snapshot.hasTextView), + "text_view_has_window": .bool(snapshot.textViewHasWindow), + "text_view_matches_panel_window": .bool(snapshot.textViewMatchesPanelWindow), + "panel_text": .string(snapshot.panelText), + "panel_attachment_count": .int(Int64(snapshot.panelAttachmentCount)), + "text_view_text": .string(snapshot.textViewText), + "text_view_attachment_count": .int(Int64(snapshot.textViewAttachmentCount)), + ])) + } + + /// `debug.textbox.interact` — drive one scripted text-box interaction. + func debugTextBoxInteract(_ params: [String: JSONValue]) -> ControlCallResult { + guard debugContext?.controlDebugTabManagerAvailable() == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + // Legacy re-trimmed and re-checked `v2String`'s output; `string` already + // yields a trimmed non-empty value, so the acceptance is identical. + guard let action = string(params, "action") else { + return .err(code: "invalid_params", message: "Missing action", data: nil) + } + let rawSurfaceID = rawString(params, "surface_id") + let target = rawSurfaceID?.trimmingCharacters(in: .whitespacesAndNewlines) + if let rawSurfaceID, + rawSurfaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .err(code: "invalid_params", message: "surface_id cannot be empty", data: nil) + } + + guard let interaction = debugContext?.controlDebugTextBoxInteract(target: target, action: action) else { + return .err(code: "not_found", message: "Terminal text box not found", data: nil) + } + return .ok(.object([ + "surface_id": .string(interaction.surfaceID.uuidString), + "surface_ref": ref(.surface, interaction.surfaceID), + "action": .string(action), + "state": interaction.state, + ])) + } + + // MARK: - debug.app.activate + + /// `debug.app.activate` — activate the app via the shared v1 body. + func debugActivateApp() -> ControlCallResult { + let resp = debugContext?.controlDebugActivateApp() ?? Self.debugContextUnavailableResponse + return resp == "OK" + ? .ok(.object([:])) + : .err(code: "internal_error", message: resp, data: nil) + } + + /// `debug.workspace_todo.checklist_add_field` — request the selected workspace's checklist add field. + func debugWorkspaceTodoChecklistAddField() -> ControlCallResult { + guard let debugContext else { + return .err(code: "unavailable", message: "Control debug context unavailable", data: nil) + } + guard let workspaceID = debugContext.controlDebugRequestWorkspaceTodoChecklistAddField() else { + return .err(code: "not_found", message: "No selected workspace", data: nil) + } + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "requested": .bool(true), + ])) + } + + // MARK: - debug.command_palette.* (event posts) + + /// The shared body of the four palette-notification commands (`toggle`, + /// `rename_tab.open`, `rename_input.interact`, + /// `rename_input.delete_backward`): identical param shape, identical + /// `not_found` payload, differing only in the posted notification. + func debugCommandPaletteEvent( + _ event: ControlDebugCommandPaletteEvent, + _ params: [String: JSONValue] + ) -> ControlCallResult { + let requestedWindowID = uuid(params, "window_id") + let posted = debugContext?.controlDebugPostCommandPaletteEvent(event, windowID: requestedWindowID) ?? false + if let requestedWindowID, !posted { + return .err(code: "not_found", message: "Window not found", data: .object([ + "window_id": .string(requestedWindowID.uuidString), + "window_ref": ref(.window, requestedWindowID), + ])) + } + return .ok(.object([:])) + } + + // MARK: - debug.command_palette.* (reads) + + /// `debug.command_palette.visible` — palette visibility in a window. + func debugCommandPaletteVisible(_ params: [String: JSONValue]) -> ControlCallResult { + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let visible = debugContext?.controlDebugCommandPaletteVisible(windowID: windowID) ?? false + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "visible": .bool(visible), + ])) + } + + /// `debug.command_palette.selection` — palette visibility + selected row. + func debugCommandPaletteSelection(_ params: [String: JSONValue]) -> ControlCallResult { + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let visible = debugContext?.controlDebugCommandPaletteVisible(windowID: windowID) ?? false + let selectedIndex = debugContext?.controlDebugCommandPaletteSelectionIndex(windowID: windowID) ?? 0 + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "visible": .bool(visible), + "selected_index": .int(Int64(max(0, selectedIndex))), + ])) + } + + /// `debug.command_palette.results` — palette query/mode/result rows. + func debugCommandPaletteResults(_ params: [String: JSONValue]) -> ControlCallResult { + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + // Legacy `params["limit"] as? Int` (NSNumber exact-integer semantics). + let requestedLimit = legacyExactInt(params["limit"]) + let limit = max(1, min(100, requestedLimit ?? 20)) + + let visible = debugContext?.controlDebugCommandPaletteVisible(windowID: windowID) ?? false + let selectedIndex = debugContext?.controlDebugCommandPaletteSelectionIndex(windowID: windowID) ?? 0 + let snapshot = debugContext?.controlDebugCommandPaletteSnapshot(windowID: windowID) ?? .empty + + let rows: [JSONValue] = Array(snapshot.results.prefix(limit)).map { row in + .object([ + "command_id": .string(row.commandID), + "title": .string(row.title), + "shortcut_hint": orNull(row.shortcutHint), + "trailing_label": orNull(row.trailingLabel), + "score": .int(Int64(row.score)), + ]) + } + + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "visible": .bool(visible), + "selected_index": .int(Int64(max(0, selectedIndex))), + "query": .string(snapshot.query), + "mode": .string(snapshot.mode), + "results": .array(rows), + ])) + } + + /// `debug.command_palette.rename_input.selection` — field-editor selection. + func debugCommandPaletteRenameInputSelection(_ params: [String: JSONValue]) -> ControlCallResult { + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + // An unwired context reads as `inactive` — the legacy body's initial + // `focused: false` payload, which survived when nothing ran. + let resolution = debugContext?.controlDebugCommandPaletteRenameInputSelection(windowID: windowID) + ?? .inactive + switch resolution { + case .windowNotFound: + return .err(code: "not_found", message: "Window not found", data: .object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ])) + case .inactive: + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "focused": .bool(false), + "selection_location": .int(0), + "selection_length": .int(0), + "text_length": .int(0), + ])) + case .active(let location, let length, let textLength): + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "focused": .bool(true), + "selection_location": .int(Int64(max(0, location))), + "selection_length": .int(Int64(max(0, length))), + "text_length": .int(Int64(max(0, textLength))), + ])) + } + } + + /// `debug.command_palette.rename_input.select_all` — read (and optionally + /// write) the select-all-on-focus setting. + func debugCommandPaletteRenameInputSelectAll(_ params: [String: JSONValue]) -> ControlCallResult { + var newValue: Bool? + if let rawEnabled = params["enabled"] { + // Legacy `rawEnabled as? Bool` on the bridged NSNumber: booleans + // pass, 0/1 numbers pass, everything else errors. + guard let enabled = legacyExactBool(rawEnabled) else { + return .err( + code: "invalid_params", + message: "enabled must be a bool", + data: .object(["enabled": rawEnabled]) + ) + } + newValue = enabled + } + let enabled = debugContext?.controlDebugCommandPaletteRenameSelectAll(updating: newValue) ?? false + return .ok(.object([ + "enabled": .bool(enabled) + ])) + } + + // MARK: - Legacy NSNumber-cast twins + + /// The typed twin of the legacy `value as? Int` on a JSON-bridged + /// `NSNumber`: exact integers pass, booleans bridge to `0`/`1`, fractional + /// or non-numeric values fail. (Distinct from `strictIntValue`, which + /// rejects booleans and accepts numeric strings.) + func legacyExactInt(_ value: JSONValue?) -> Int? { + switch value { + case .int(let v): + return Int(exactly: v) + case .double(let v): + return Int(exactly: v) + case .bool(let v): + return v ? 1 : 0 + default: + return nil + } + } + + /// The typed twin of the legacy `value as? Bool` on a JSON-bridged + /// `NSNumber`: booleans pass, and exactly-`0`/`1` numbers bridge to + /// `false`/`true` (Foundation's `Bool(exactly:)` semantics). + func legacyExactBool(_ value: JSONValue) -> Bool? { + switch value { + case .bool(let v): + return v + case .int(0): + return false + case .int(1): + return true + case .double(let v) where v == 0: + return false + case .double(let v) where v == 1: + return true + default: + return nil + } + } +} +#endif diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift index 118c7c7bf193..227e9d390286 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift @@ -38,6 +38,13 @@ public protocol ControlDebugContext: AnyObject { /// unavailable (the legacy `unavailable` error). func controlDebugSessionSnapshotSeedScrollback(charactersPerTerminal: Int) -> JSONValue? + /// Reads the DEBUG-only process enumeration, filtering, apply, and lsof + /// counters without mutating app or focus state. + func controlDebugReadProcessPerformanceMetrics() -> JSONValue? + + /// Resets the DEBUG-only process counters and returns the zeroed snapshot. + func controlDebugResetProcessPerformanceMetrics() -> JSONValue? + // MARK: - v1-shared command forwards (raw v1 response strings) /// Runs the shared v1 `set_shortcut` body for `debug.shortcut.set`. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Performance/ControlCommandCoordinator+Performance.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Performance/ControlCommandCoordinator+Performance.swift new file mode 100644 index 000000000000..5ab110c70158 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Performance/ControlCommandCoordinator+Performance.swift @@ -0,0 +1,30 @@ +internal import Foundation + +extension ControlCommandCoordinator { + /// Handles the opt-in aggregate performance counter surface in optimized + /// builds. The context owns collection and redaction; this package only + /// routes typed JSON values. + func handlePerformance(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "performance.metrics.read": + return performanceMetricsResult(context?.controlPerformanceMetricsRead()) + case "performance.metrics.reset": + return performanceMetricsResult(context?.controlPerformanceMetricsReset()) + case "performance.metrics.stop": + return performanceMetricsResult(context?.controlPerformanceMetricsStop()) + default: + return nil + } + } + + private func performanceMetricsResult(_ payload: JSONValue?) -> ControlCallResult { + guard let payload else { + return .err( + code: "unavailable", + message: "Performance metrics unavailable", + data: nil + ) + } + return .ok(payload) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Performance/ControlPerformanceContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Performance/ControlPerformanceContext.swift new file mode 100644 index 000000000000..94f317faa3fa --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Performance/ControlPerformanceContext.swift @@ -0,0 +1,17 @@ +public import Foundation + +/// Release-safe observability seam for opt-in, aggregate performance counters. +/// Payloads contain counts and durations only, never workspace names, paths, +/// command lines, or terminal contents. +@MainActor +public protocol ControlPerformanceContext: AnyObject { + func controlPerformanceMetricsRead() -> JSONValue? + func controlPerformanceMetricsReset() -> JSONValue? + func controlPerformanceMetricsStop() -> JSONValue? +} + +public extension ControlPerformanceContext { + func controlPerformanceMetricsRead() -> JSONValue? { nil } + func controlPerformanceMetricsReset() -> JSONValue? { nil } + func controlPerformanceMetricsStop() -> JSONValue? { nil } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketClientAuthorization.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketClientAuthorization.swift index 16f93d1b84a5..51ab63c54644 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketClientAuthorization.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Transport/SocketClientAuthorization.swift @@ -1,8 +1,10 @@ public import Darwin -/// Authorizes peer processes for cmux-only control socket requests. -public struct SocketClientAuthorization { - /// Creates an authorization helper with no retained process state. +/// Authorizes one peer connection for cmux-only control socket requests. +public struct SocketClientAuthorization: Sendable { + private var cachedAncestryAuthorization: (peerProcessID: pid_t, isAllowed: Bool)? + + /// Creates an authorization helper for one accepted socket connection. public init() {} /// Returns whether a peer process is allowed to use cmux-only socket operations. @@ -29,11 +31,36 @@ public struct SocketClientAuthorization { return false } + /// Evaluates and caches process ancestry for one peer PID. + /// + /// Call this when connection admission must fall back to ancestry before + /// the first command is available. Later command authorization reuses the + /// cached result and does not walk the process tree again. + /// + /// - Parameters: + /// - peerProcessID: The PID reported by the accepted socket. + /// - isDescendant: Predicate that verifies current process ancestry. + /// - Returns: The cached or newly evaluated ancestry result. + public mutating func cacheAncestryAuthorization( + peerProcessID: pid_t?, + isDescendant: (pid_t) -> Bool + ) -> Bool { + guard let peerProcessID else { return false } + if let cachedAncestryAuthorization, + cachedAncestryAuthorization.peerProcessID == peerProcessID { + return cachedAncestryAuthorization.isAllowed + } + let peerIsDescendant = isDescendant(peerProcessID) + cachedAncestryAuthorization = (peerProcessID, peerIsDescendant) + return peerIsDescendant + } + /// Returns the command carried by an authorized cmux-only request. /// - /// Descendants retain the existing process-tree authorization. The - /// capability parameters form the runtime seam for terminals whose - /// process trees are later reparented by a multiplexer. + /// A valid same-user capability is accepted before process ancestry is + /// evaluated. Ordinary clients retain process-tree authorization, with one + /// ancestry result cached for the lifetime of this authorization helper. + /// Reusing a helper with a different peer PID invalidates that cache. /// /// - Parameters: /// - command: The raw command line received from the client. @@ -42,7 +69,7 @@ public struct SocketClientAuthorization { /// - capabilityAuthority: The authority that verifies inherited tokens. /// - isDescendant: Predicate that verifies current process ancestry. /// - Returns: The unwrapped command when authorized, otherwise `nil`. - public func authorizedCommand( + public mutating func authorizedCommand( _ command: String, peerProcessID: pid_t?, peerHasSameUID: Bool, @@ -50,14 +77,16 @@ public struct SocketClientAuthorization { isDescendant: (pid_t) -> Bool ) -> String? { let envelope = SocketClientCapabilityCommand(command) - if let peerProcessID, isDescendant(peerProcessID) { - return envelope?.command ?? command - } - guard peerHasSameUID, - let envelope, - capabilityAuthority.verifies(envelope.capability) else { - return nil + if peerHasSameUID, + let envelope, + capabilityAuthority.verifies(envelope.capability) { + return envelope.command } - return envelope.command + + guard cacheAncestryAuthorization( + peerProcessID: peerProcessID, + isDescendant: isDescendant + ) else { return nil } + return envelope?.command ?? command } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index ef88ea186a26..2644643df9ae 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -97,6 +97,13 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "mobile.terminal.set_font", "system.top", "system.memory", + // Owner attribution performs a fresh process + listener capture and + // awaits utility tasks. It never touches UI state, so running it on the + // main actor would contaminate the latency path it exists to prove. + "performance.metrics.exercise_process", + // Uses an isolated temporary Git repository and in-memory PR host. Git + // scanning stays off-main; only the PR state machine hops to MainActor. + "performance.metrics.exercise_git_pr", // `surface.read_text` reads a terminal's visible or full-scrollback // text and formats it (line tailing, candidate scoring, base64 // encoding). On the main actor that formatting stalls the run loop diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift index 6ef7a8b3e931..05b8ce0d0396 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift @@ -11,6 +11,8 @@ import Foundation extension ControlDebugContext { func controlDebugSessionSnapshotBenchmark(includeScrollback: Bool, persist: Bool) -> JSONValue? { nil } func controlDebugSessionSnapshotSeedScrollback(charactersPerTerminal: Int) -> JSONValue? { nil } + func controlDebugReadProcessPerformanceMetrics() -> JSONValue? { nil } + func controlDebugResetProcessPerformanceMetrics() -> JSONValue? { nil } func controlDebugSetShortcut(arguments: String) -> String { "ERROR: not implemented" } func controlDebugSimulateShortcut(combo: String) -> String { "ERROR: not implemented" } func controlDebugActivateApp() -> String { "ERROR: not implemented" } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugV1Tests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugV1Tests.swift index 0d25b6e5e16a..f92c6a9a0fdd 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugV1Tests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugV1Tests.swift @@ -17,6 +17,13 @@ private final class FakeDebugV1ControlCommandContext: ControlCommandContext { var rightSidebarResolution: ControlDebugRightSidebarFocusResolution = .windowNotFound var remoteTmuxSizingPayload: JSONValue? + var processMetrics = JSONValue.object([ + "process_snapshots": .object(["capture_started": .int(3)]) + ]) + var processMetricsReadCount = 0 + var processMetricsResetCount = 0 + var typedTexts: [String] = [] + func controlDebugSetShortcut(arguments: String) -> String { setShortcutArguments = arguments return setShortcutResponse @@ -32,6 +39,24 @@ private final class FakeDebugV1ControlCommandContext: ControlCommandContext { return rightSidebarResolution } + func controlDebugReadProcessPerformanceMetrics() -> JSONValue? { + processMetricsReadCount += 1 + return processMetrics + } + + func controlDebugResetProcessPerformanceMetrics() -> JSONValue? { + processMetricsResetCount += 1 + processMetrics = .object([ + "process_snapshots": .object(["capture_started": .int(0)]) + ]) + return processMetrics + } + + func controlDebugTypeText(_ text: String) -> ControlDebugTypeResolution { + typedTexts.append(text) + return .inserted + } + func controlDebugRemoteTmuxSizingSettled() -> JSONValue? { remoteTmuxSizingPayload } @@ -121,5 +146,42 @@ struct ControlCommandCoordinatorDebugV1Tests { let reply = coordinator.handleDebugV1(command: "debug_right_sidebar_focus", args: "split") #expect(reply == "ERROR: mode=split active= visible=0 context=0 state=0 focus=0") } + + @Test func processMetricsReadAndResetUseSharedDebugDomain() { + let (coordinator, context) = makeCoordinator() + let read = coordinator.handle(ControlRequest( + id: .int(1), + method: "debug.process_metrics.read", + params: [:] + )) + #expect(read == .ok(.object([ + "process_snapshots": .object(["capture_started": .int(3)]) + ]))) + #expect(context.processMetricsReadCount == 1) + + let reset = coordinator.handle(ControlRequest( + id: .int(2), + method: "debug.process_metrics.reset", + params: [:] + )) + #expect(reset == .ok(.object([ + "process_snapshots": .object(["capture_started": .int(0)]) + ]))) + #expect(context.processMetricsResetCount == 1) + } + + @Test func typingDoesNotReadOrResetProcessMetrics() { + let (coordinator, context) = makeCoordinator() + let result = coordinator.handle(ControlRequest( + id: .int(1), + method: "debug.type", + params: ["text": .string("typing workload")] + )) + + #expect(result == .ok(.object([:]))) + #expect(context.typedTexts == ["typing workload"]) + #expect(context.processMetricsReadCount == 0) + #expect(context.processMetricsResetCount == 0) + } } #endif diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorPerformanceTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorPerformanceTests.swift new file mode 100644 index 000000000000..d37d951ce60e --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorPerformanceTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import CmuxControlSocket + +@MainActor +private final class FakePerformanceControlCommandContext: ControlCommandContext { + private(set) var calls: [String] = [] + + func controlPerformanceMetricsRead() -> JSONValue? { + calls.append("read") + return .object(["enabled": .bool(true)]) + } + + func controlPerformanceMetricsReset() -> JSONValue? { + calls.append("reset") + return .object(["enabled": .bool(true), "reset": .bool(true)]) + } + + func controlPerformanceMetricsStop() -> JSONValue? { + calls.append("stop") + return .object(["enabled": .bool(false)]) + } +} + +@MainActor +@Suite("ControlCommandCoordinator performance metrics") +struct ControlCommandCoordinatorPerformanceTests { + @Test func routesReleaseSafeReadResetAndStop() { + let context = FakePerformanceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + + #expect(coordinator.handle(request("performance.metrics.read")) == .ok(.object([ + "enabled": .bool(true), + ]))) + #expect(coordinator.handle(request("performance.metrics.reset")) == .ok(.object([ + "enabled": .bool(true), + "reset": .bool(true), + ]))) + #expect(coordinator.handle(request("performance.metrics.stop")) == .ok(.object([ + "enabled": .bool(false), + ]))) + #expect(context.calls == ["read", "reset", "stop"]) + } + + @Test func unrelatedMethodFallsThrough() { + let coordinator = ControlCommandCoordinator(context: FakePerformanceControlCommandContext()) + #expect(coordinator.handle(request("performance.unowned")) == nil) + } + + private func request(_ method: String) -> ControlRequest { + ControlRequest(id: .int(1), method: method, params: [:]) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 650a2bd4f8c8..93818f5c4e6d 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -30,6 +30,8 @@ struct ControlCommandExecutionPolicyTests { for method in [ "system.ping", "system.capabilities", "auth.status", "auth.sign_in_url", "feed.push", "browser.download.wait", "system.top", "system.memory", + "performance.metrics.exercise_process", + "performance.metrics.exercise_git_pr", "workspace.remote.pty_bridge", "workspace.env", "sidebar.custom.reload", "sidebar.custom.open", "debug.sidebar.simulate_drag", "mobile.attach_ticket.create", diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketCapabilityRebindTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketCapabilityRebindTests.swift index e3e1877fe2f7..3085d176f506 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketCapabilityRebindTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketCapabilityRebindTests.swift @@ -52,14 +52,20 @@ struct SocketCapabilityRebindTests { } let command = "hooks claude prompt-submit" - let authorized = SocketClientAuthorization().authorizedCommand( + var authorization = SocketClientAuthorization() + var ancestryEvaluationCount = 0 + let authorized = authorization.authorizedCommand( envelope.wrap(command), peerProcessID: reboundConnection.peerProcessID, peerHasSameUID: true, capabilityAuthority: authority, - isDescendant: { _ in false } + isDescendant: { _ in + ancestryEvaluationCount += 1 + return false + } ) #expect(authorized == command) + #expect(ancestryEvaluationCount == 0) } private func connect(to path: String) -> Int32 { diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientAuthorizationTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientAuthorizationTests.swift index 39fc8ca7a183..2d942b359a04 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientAuthorizationTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientAuthorizationTests.swift @@ -31,6 +31,7 @@ struct SocketClientAuthorizationTests { } @Test func cmuxOnlyAllowsReparentedClientWithInheritedCapability() throws { + var authorization = authorization let authority = SocketClientCapabilityAuthority( secret: Data(repeating: 0xA5, count: SocketClientCapabilityAuthority.secureByteCount), audience: "com.cmuxterm.test" @@ -40,17 +41,23 @@ struct SocketClientAuthorizationTests { ) let envelope = try #require(SocketClientCapabilityEnvelope(capability: capability)) let command = "hooks claude prompt-submit" + var ancestryEvaluationCount = 0 #expect(authorization.authorizedCommand( envelope.wrap(command), peerProcessID: 123, peerHasSameUID: true, capabilityAuthority: authority, - isDescendant: { _ in false } + isDescendant: { _ in + ancestryEvaluationCount += 1 + return false + } ) == command) + #expect(ancestryEvaluationCount == 0) } @Test func cmuxOnlyRejectsReparentedClientWithoutCapability() { + var authorization = authorization let authority = SocketClientCapabilityAuthority( secret: Data(repeating: 0xA5, count: SocketClientCapabilityAuthority.secureByteCount), audience: "com.cmuxterm.test" @@ -65,6 +72,7 @@ struct SocketClientAuthorizationTests { } @Test func cmuxOnlyRejectsCapabilityFromDifferentUser() throws { + var authorization = authorization let authority = SocketClientCapabilityAuthority( secret: Data(repeating: 0xA5, count: SocketClientCapabilityAuthority.secureByteCount), audience: "com.cmuxterm.test" @@ -81,4 +89,94 @@ struct SocketClientAuthorizationTests { isDescendant: { _ in false } ) == nil) } + + @Test func cmuxOnlyChecksOrdinaryDescendantAncestryOncePerConnection() { + var authorization = authorization + let authority = SocketClientCapabilityAuthority( + secret: Data(repeating: 0xA5, count: SocketClientCapabilityAuthority.secureByteCount), + audience: "com.cmuxterm.test" + ) + var ancestryEvaluationCount = 0 + let isDescendant: (pid_t) -> Bool = { pid in + ancestryEvaluationCount += 1 + return pid == 123 + } + + #expect(authorization.authorizedCommand( + "ping", + peerProcessID: 123, + peerHasSameUID: true, + capabilityAuthority: authority, + isDescendant: isDescendant + ) == "ping") + #expect(authorization.authorizedCommand( + "system.capabilities", + peerProcessID: 123, + peerHasSameUID: true, + capabilityAuthority: authority, + isDescendant: isDescendant + ) == "system.capabilities") + #expect(ancestryEvaluationCount == 1) + } + + @Test func exhaustedPreauthorizationCachesDescendantForLaterCommands() { + var authorization = authorization + let authority = SocketClientCapabilityAuthority( + secret: Data(repeating: 0xA5, count: SocketClientCapabilityAuthority.secureByteCount), + audience: "com.cmuxterm.test" + ) + var ancestryEvaluationCount = 0 + let isDescendant: (pid_t) -> Bool = { pid in + ancestryEvaluationCount += 1 + return pid == 123 + } + + let admitted = authorization.cacheAncestryAuthorization( + peerProcessID: 123, + isDescendant: isDescendant + ) + #expect(admitted) + #expect(authorization.authorizedCommand( + "ping", + peerProcessID: 123, + peerHasSameUID: true, + capabilityAuthority: authority, + isDescendant: isDescendant + ) == "ping") + #expect(authorization.authorizedCommand( + "system.capabilities", + peerProcessID: 123, + peerHasSameUID: true, + capabilityAuthority: authority, + isDescendant: isDescendant + ) == "system.capabilities") + #expect(ancestryEvaluationCount == 1) + } + + @Test func exhaustedPreauthorizationRejectsAndCachesNonDescendant() { + var authorization = authorization + let authority = SocketClientCapabilityAuthority( + secret: Data(repeating: 0xA5, count: SocketClientCapabilityAuthority.secureByteCount), + audience: "com.cmuxterm.test" + ) + var ancestryEvaluationCount = 0 + let isDescendant: (pid_t) -> Bool = { _ in + ancestryEvaluationCount += 1 + return false + } + + let admitted = authorization.cacheAncestryAuthorization( + peerProcessID: 123, + isDescendant: isDescendant + ) + #expect(!admitted) + #expect(authorization.authorizedCommand( + "ping", + peerProcessID: 123, + peerHasSameUID: true, + capabilityAuthority: authority, + isDescendant: isDescendant + ) == nil) + #expect(ancestryEvaluationCount == 1) + } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientPreauthorizationLimiterTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientPreauthorizationLimiterTests.swift index 24dba518c7c6..fefff7b71b7b 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientPreauthorizationLimiterTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketClientPreauthorizationLimiterTests.swift @@ -17,4 +17,49 @@ struct SocketClientPreauthorizationLimiterTests { let replacement = await limiter.claim() #expect(replacement) } + + @Test func exhaustedDescendantBypassDoesNotChangeClaimAccounting() async { + let limiter = SocketClientPreauthorizationLimiter(maximumConcurrentClaims: 1) + var authorization = SocketClientAuthorization() + var ancestryEvaluationCount = 0 + + #expect(await limiter.claim()) + #expect(!(await limiter.claim())) + let admitted = authorization.cacheAncestryAuthorization( + peerProcessID: 123, + isDescendant: { pid in + ancestryEvaluationCount += 1 + return pid == 123 + } + ) + #expect(admitted) + #expect(ancestryEvaluationCount == 1) + + await limiter.release() + #expect(await limiter.claim()) + await limiter.release() + await limiter.release() + #expect(await limiter.claim()) + await limiter.release() + #expect(await limiter.claim()) + await limiter.release() + } + + @Test func exhaustedNonDescendantDoesNotConsumeOrReleaseAClaim() async { + let limiter = SocketClientPreauthorizationLimiter(maximumConcurrentClaims: 1) + var authorization = SocketClientAuthorization() + + #expect(await limiter.claim()) + #expect(!(await limiter.claim())) + let admitted = authorization.cacheAncestryAuthorization( + peerProcessID: 123, + isDescendant: { _ in false } + ) + #expect(!admitted) + + #expect(!(await limiter.claim())) + await limiter.release() + #expect(await limiter.claim()) + await limiter.release() + } } diff --git a/Packages/macOS/CmuxFoundation/Package.swift b/Packages/macOS/CmuxFoundation/Package.swift index 35bcc7161c28..5a8eacc82a82 100644 --- a/Packages/macOS/CmuxFoundation/Package.swift +++ b/Packages/macOS/CmuxFoundation/Package.swift @@ -14,8 +14,13 @@ let package = Package( ), ], targets: [ + .target( + name: "CmuxFoundationAtomicsC", + publicHeadersPath: "include" + ), .target( name: "CmuxFoundation", + dependencies: ["CmuxFoundationAtomicsC"], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicBooleanGate.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicBooleanGate.swift new file mode 100644 index 000000000000..87b8b5f8b2b3 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/AtomicBooleanGate.swift @@ -0,0 +1,48 @@ +internal import CmuxFoundationAtomicsC + +/// A macOS 14-compatible atomic Boolean used as a lock-free disabled-path gate. +/// +/// Reads use relaxed ordering because callers recheck authoritative state under +/// their own synchronization after observing `true`. Stores use release ordering +/// so a transition to `false` is visible before the owner disables its state. +public final class AtomicBooleanGate: @unchecked Sendable { + // The pointer is allocated once and never changes. C11 owns every access to + // its pointee, so concurrent calls do not form overlapping Swift `inout` + // accesses and Thread Sanitizer sees the atomic synchronization directly. + nonisolated(unsafe) private let storage: UnsafeMutablePointer + + /// Creates a gate with the supplied initial value. + /// + /// - Parameter initialValue: The value returned until the first store. + public init(_ initialValue: Bool) { + storage = .allocate(capacity: 1) + CmuxAtomicBooleanInitialize(storage, initialValue) + } + + deinit { + storage.deallocate() + } + + /// Returns the current value with relaxed memory ordering. + @inline(__always) + public func loadRelaxed() -> Bool { + CmuxAtomicBooleanLoadRelaxed(storage) + } + + /// Returns the current value with acquire ordering. + /// + /// Use this when observing `true` activates behavior that must see a + /// preceding release-published transition before the caller proceeds. + @inline(__always) + public func loadAcquire() -> Bool { + CmuxAtomicBooleanLoadAcquire(storage) + } + + /// Publishes a new value with release memory ordering. + /// + /// - Parameter value: The value subsequent loads should observe. + @inline(__always) + public func storeRelease(_ value: Bool) { + CmuxAtomicBooleanStoreRelease(storage, value) + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/LatestWinsBatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/LatestWinsBatcher.swift new file mode 100644 index 000000000000..9fe6d7241f00 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Concurrency/LatestWinsBatcher.swift @@ -0,0 +1,160 @@ +public import Foundation + +/// Coalesces keyed values until a burst is quiet, while bounding total deferral. +/// +/// A later submission for the same key replaces the earlier value. The quiet +/// deadline is rearmed for every submission; the maximum deadline is anchored +/// to the first submission in the batch. Whichever deadline fires first drains +/// the batch once on the main actor. +@MainActor +public final class LatestWinsBatcher { + /// Cancels a scheduled deadline. + public typealias Cancellation = @MainActor () -> Void + + /// Schedules one main-actor action after the requested delay. + public typealias Scheduler = @MainActor ( + TimeInterval, + @escaping @MainActor () -> Void + ) -> Cancellation + + typealias DeadlineNow = @MainActor () -> ContinuousClock.Instant + typealias DeadlineSleep = @MainActor (ContinuousClock.Instant) async throws -> Void + typealias DeadlineTaskStarter = @MainActor ( + @escaping @MainActor () async -> Void + ) -> Cancellation + + private let quietDelay: TimeInterval + private let maximumDelay: TimeInterval + private let schedule: Scheduler + private var pending: [Key: Value] = [:] + private var cancelQuietDeadline: Cancellation? + private var cancelMaximumDeadline: Cancellation? + + /// Creates a latest-wins batcher. + /// + /// - Parameters: + /// - quietDelay: Time without another submission before the batch drains. + /// - maximumDelay: Maximum time from the first submission to the drain. + /// - scheduler: Deadline scheduler. Tests inject a virtual scheduler. + public init( + quietDelay: TimeInterval, + maximumDelay: TimeInterval, + scheduler: @escaping Scheduler + ) { + self.quietDelay = max(0, quietDelay) + self.maximumDelay = max(0, maximumDelay) + self.schedule = scheduler + } + + /// Creates a batcher backed by cancellable continuous-clock deadlines. + /// + /// - Parameters: + /// - quietDelay: Time without another submission before the batch drains. + /// - maximumDelay: Maximum time from the first submission to the drain. + public convenience init( + quietDelay: TimeInterval, + maximumDelay: TimeInterval + ) { + self.init( + quietDelay: quietDelay, + maximumDelay: maximumDelay, + scheduler: LatestWinsBatcher.systemScheduler + ) + } + + /// Adds or replaces one keyed value and schedules a single batch drain. + /// + /// - Parameters: + /// - value: Latest value for `key`. + /// - key: Stable identity whose pending value should be replaced. + /// - drain: Consumer invoked with the complete pending batch. + public func submit( + _ value: Value, + for key: Key, + drain: @escaping @MainActor ([Key: Value]) -> Void + ) { + pending[key] = value + + cancelQuietDeadline?() + cancelQuietDeadline = schedule(quietDelay) { [weak self] in + self?.drain(using: drain) + } + + guard cancelMaximumDeadline == nil else { return } + cancelMaximumDeadline = schedule(maximumDelay) { [weak self] in + self?.drain(using: drain) + } + } + + /// Immediately drains the current batch, if any. + /// + /// - Parameter drain: Consumer invoked with the complete pending batch. + public func flushNow(_ drain: @MainActor ([Key: Value]) -> Void) { + self.drain(using: drain) + } + + /// Discards pending values and cancels both deadlines. + public func cancel() { + cancelDeadlines() + pending.removeAll(keepingCapacity: true) + } + + private func drain(using consumer: @MainActor ([Key: Value]) -> Void) { + guard !pending.isEmpty else { + cancelDeadlines() + return + } + let batch = pending + pending.removeAll(keepingCapacity: true) + cancelDeadlines() + consumer(batch) + } + + private func cancelDeadlines() { + cancelQuietDeadline?() + cancelMaximumDeadline?() + cancelQuietDeadline = nil + cancelMaximumDeadline = nil + } + + private static func systemScheduler( + delay: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> Cancellation { + let clock = ContinuousClock() + return absoluteDeadlineScheduler( + now: { clock.now }, + sleepUntil: { deadline in + try await clock.sleep(until: deadline) + }, + startTask: { operation in + let task = Task { @MainActor in + await operation() + } + return { task.cancel() } + } + )(delay, action) + } + + /// Builds a scheduler whose deadline is captured before its task is + /// enqueued. Main-actor congestion can delay task startup, so computing the + /// delay inside the task would extend both quiet and maximum batching bounds. + static func absoluteDeadlineScheduler( + now: @escaping DeadlineNow, + sleepUntil: @escaping DeadlineSleep, + startTask: @escaping DeadlineTaskStarter + ) -> Scheduler { + { delay, action in + let deadline = now().advanced(by: .seconds(max(0, delay))) + return startTask { + do { + try await sleepUntil(deadline) + } catch { + return + } + guard !Task.isCancelled else { return } + action() + } + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift index 08fde56a3b44..a286bf714b54 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileSystemEventStream.swift @@ -1,8 +1,19 @@ import CoreServices import Foundation +/// A stoppable source of raw filesystem-event identities. +/// +/// `RecursivePathWatcher` owns this source and applies its coalescing policy to +/// ``events``. Keeping acquisition behind this small protocol separates the +/// FSEvents transport from the event pipeline without exposing either one's +/// mutable state. +protocol FileWatchEventSource: Sendable { + var events: AsyncStream { get } + func stop() +} + /// A thin owner of an `FSEventStream` that reports raw filesystem events through -/// a `@Sendable` sink. +/// an `AsyncStream`. /// /// `FSEventStream` is a C API with no async-native replacement, and it is the /// only macOS primitive that watches a *set of paths recursively* with a single @@ -14,9 +25,8 @@ import Foundation /// **Threading.** Every instance shares one serial dispatch queue (rather than /// one queue per stream) to bound thread usage when many workspaces are tracked. /// All mutable state is touched only on that queue, which is why the type is -/// `@unchecked Sendable`. ``onEvent`` fires on the shared queue, so it MUST be -/// non-blocking — a slow sink would serialize behind every other stream's -/// teardown. The production sink only spawns a `Task` and returns. +/// `@unchecked Sendable`. The callback only yields into ``events``, so it never +/// blocks the shared queue behind a consumer's work. /// /// **Context lifetime.** The stream is registered with FSEvents as its own /// context `info` pointer, passed *unretained* (no `retain`/`release` callbacks). @@ -26,7 +36,7 @@ import Foundation /// completion before the `queue.sync` teardown block, and none is delivered /// after `FSEventStreamInvalidate`. No callback ever touches a freed instance, /// so a separately retained context box is unnecessary. -final class FileSystemEventStream: @unchecked Sendable { +final class FileSystemEventStream: FileWatchEventSource, @unchecked Sendable { private static let queueSpecificKey = DispatchSpecificKey() private static let queue: DispatchQueue = { let queue = DispatchQueue(label: "com.cmux.recursive-path-watcher", qos: .utility) @@ -41,14 +51,26 @@ final class FileSystemEventStream: @unchecked Sendable { /// stream is recovered from the context's `info` pointer instead — passed /// *unretained* (see the type's "Context lifetime" note), so this uses /// `takeUnretainedValue()` and never adjusts the reference count. - private static let callback: FSEventStreamCallback = { _, info, _, _, _, _ in - guard let info else { return } - Unmanaged.fromOpaque(info).takeUnretainedValue().onEvent() + private static let callback: FSEventStreamCallback = { + _, info, eventCount, _, eventFlags, eventIDs in + guard let info, eventCount > 0 else { return } + var latestEventID = eventIDs[0] + var combinedFlags: FSEventStreamEventFlags = 0 + for index in 0.. + .fromOpaque(info) + .takeUnretainedValue() + .continuation.yield(eventIdentity( + latestEventID: latestEventID, + flags: combinedFlags + )) } - /// The non-blocking sink invoked on the shared queue for each coalesced batch - /// of filesystem events. - private let onEvent: @Sendable () -> Void + let events: AsyncStream + private let continuation: AsyncStream.Continuation private var stream: FSEventStreamRef? /// Creates and starts a stream for `paths`. @@ -56,13 +78,14 @@ final class FileSystemEventStream: @unchecked Sendable { /// - Parameters: /// - paths: The files and directories to watch. Must be non-empty. /// - latency: The FSEvents coalescing latency in seconds. - /// - onEvent: A non-blocking sink invoked on the shared queue for each - /// coalesced batch of filesystem events. /// - Returns: `nil` if `paths` is empty or the underlying `FSEventStream` /// could not be created or started. - init?(paths: [String], latency: TimeInterval, onEvent: @escaping @Sendable () -> Void) { + init?( + paths: [String], + latency: TimeInterval + ) { guard !paths.isEmpty else { return nil } - self.onEvent = onEvent + (self.events, self.continuation) = AsyncStream.makeStream() self.stream = nil var context = FSEventStreamContext( @@ -82,6 +105,7 @@ final class FileSystemEventStream: @unchecked Sendable { latency, flags ) else { + continuation.finish() return nil } self.stream = stream @@ -92,6 +116,22 @@ final class FileSystemEventStream: @unchecked Sendable { } } + static func eventIdentity( + latestEventID: FSEventStreamEventId, + flags: FSEventStreamEventFlags + ) -> FileWatchEventIdentity { + if flags & FSEventStreamEventFlags(kFSEventStreamEventFlagEventIdsWrapped) != 0 { + return .eventIDsWrapped + } + let conservativeFlags = FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs) + | FSEventStreamEventFlags(kFSEventStreamEventFlagUserDropped) + | FSEventStreamEventFlags(kFSEventStreamEventFlagKernelDropped) + if flags & conservativeFlags != 0 { + return .mustRescan + } + return .stable(FileWatchEventID(rawValue: latestEventID)) + } + /// Stops and tears down the stream. Idempotent. /// /// Teardown runs synchronously on the shared queue so it completes before the @@ -105,6 +145,7 @@ final class FileSystemEventStream: @unchecked Sendable { } else { Self.queue.sync { stopOnQueue() } } + continuation.finish() } private func stopOnQueue() { diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchEventID.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchEventID.swift new file mode 100644 index 000000000000..3e6b5b98a514 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/FileWatchEventID.swift @@ -0,0 +1,43 @@ +/// Stable FSEvents identifier for one filesystem event. +/// +/// Event IDs let independent watchers of the same paths recognize duplicate +/// delivery without inferring identity from timing. Within a throttle window, +/// ``RecursivePathWatcher`` emits the greatest ID because a scan after that +/// event observes the filesystem state produced by every earlier event. +public nonisolated struct FileWatchEventID: Equatable, Hashable, Comparable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// Whether an FSEvents batch has a stable deduplication watermark. +public nonisolated enum FileWatchEventIdentity: Equatable, Sendable { + /// Normal delivery. Equal or older IDs from another watcher are duplicate + /// observations of filesystem state already covered by this watermark. + case stable(FileWatchEventID) + /// FSEvents reported a dropped batch or requested a subtree rescan. Every + /// delivery must advance authority because its exact event identity is not + /// trustworthy. + case mustRescan + /// FSEvents IDs wrapped. The consumer must advance authority. Consumers that + /// compare IDs as unsigned serial numbers retain the prior watermark so a + /// wrapped low ID is newer while delayed pre-wrap delivery stays older. + case eventIDsWrapped + + func merged(with other: Self) -> Self { + switch (self, other) { + case (.eventIDsWrapped, _), (_, .eventIDsWrapped): + return .eventIDsWrapped + case (.mustRescan, _), (_, .mustRescan): + return .mustRescan + case (.stable(let first), .stable(let second)): + return .stable(max(first, second)) + } + } +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift index 3cc657ed3e37..9611918e9a57 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/FileWatch/RecursivePathWatcher.swift @@ -1,7 +1,73 @@ import Foundation +/// Stateful policy for folding raw filesystem identities into coalesced yields. +/// +/// The watcher owns scheduling and stream lifecycle. This value owns only the +/// deterministic ordering, reset, and acknowledgement rules applied at those +/// boundaries. +struct FileWatchEventCoalescingState { + private var isThrottleArmed = false + private var pendingIdentity: FileWatchEventIdentity? + private var conservativeIdentity: FileWatchEventIdentity? + private var conservativeIdentityWasYielded = false + + /// Records a raw event and returns whether the caller must arm the throttle. + mutating func record(_ identity: FileWatchEventIdentity) -> Bool { + switch identity { + case .stable: + break + case .mustRescan: + conservativeIdentityWasYielded = false + if conservativeIdentity != .eventIDsWrapped { + conservativeIdentity = .mustRescan + } + case .eventIDsWrapped: + conservativeIdentityWasYielded = false + conservativeIdentity = .eventIDsWrapped + } + let effectiveIdentity = conservativeIdentity ?? identity + pendingIdentity = pendingIdentity?.merged(with: effectiveIdentity) + ?? effectiveIdentity + guard !isThrottleArmed else { return false } + isThrottleArmed = true + return true + } + + /// Ends the active window and returns the identity ready for delivery. + mutating func takePendingIdentity() -> FileWatchEventIdentity? { + isThrottleArmed = false + defer { pendingIdentity = nil } + return pendingIdentity + } + + /// Reconciles conservative reset delivery with the output buffer's result. + mutating func recordYield( + _ result: AsyncStream.Continuation.YieldResult + ) { + guard conservativeIdentity != nil else { return } + switch result { + case .enqueued: + if conservativeIdentityWasYielded { + conservativeIdentity = nil + conservativeIdentityWasYielded = false + } else { + conservativeIdentityWasYielded = true + } + case .dropped: + // The newest-element buffer replaced an unconsumed conservative + // signal with the same conservative identity. Keep repeating it + // until a later enqueue proves the consumer drained the buffer. + conservativeIdentityWasYielded = true + case .terminated: + conservativeIdentityWasYielded = false + @unknown default: + conservativeIdentityWasYielded = false + } + } +} + /// Watches a set of filesystem paths recursively and reports changes as a -/// coalesced `AsyncStream`. +/// coalesced `AsyncStream`. /// /// Construct one with the paths to watch (the caller resolves which paths matter /// for its domain) and consume ``events`` to react to changes: @@ -38,19 +104,22 @@ public actor RecursivePathWatcher { /// recreating an equivalent watcher. public nonisolated let watchedPaths: [String] - /// Stream of coalesced change events. Yields one element per throttle window - /// in which at least one filesystem event affected a watched path. Finishes - /// when ``stop()`` is called or the watcher is deallocated. - public nonisolated let events: AsyncStream + /// Stream of coalesced change events with their FSEvents watermark. + /// + /// Independent watchers of the same path set receive the same identifier + /// for the same underlying event, allowing process-wide consumers to + /// coalesce duplicate delivery without a timing heuristic. The single + /// newest-element buffer bounds memory while a consumer is busy. Stable IDs + /// are cumulative watermarks. A dropped/wrapped batch remains conservative + /// until a later yield proves the consumer drained that signal, then stable + /// watermark delivery resumes. + public nonisolated let events: AsyncStream - private let continuation: AsyncStream.Continuation + private let continuation: AsyncStream.Continuation private let clock: any FileWatchClock - // nil only for the test-throttle initializer, which drives the throttle - // directly without a real FSEventStream. - private let eventStream: FileSystemEventStream? - // Finishing this ends the pump task (see init); raw FS events flow through it. - private let rawContinuation: AsyncStream.Continuation + private let eventSource: any FileWatchEventSource private var throttleTask: Task? + private var coalescingState = FileWatchEventCoalescingState() private var isStopped = false /// The `FSEventStream` coalescing latency, in seconds. @@ -73,79 +142,67 @@ public actor RecursivePathWatcher { clock: any FileWatchClock = SystemFileWatchClock() ) { guard !paths.isEmpty else { return nil } - self.watchedPaths = paths + guard let eventSource = FileSystemEventStream( + paths: paths, + latency: Self.streamLatency + ) else { return nil } + self.init(watchedPaths: paths, eventSource: eventSource, clock: clock) + } + + /// Creates a watcher over an event source. + /// + /// The FSEvents transport and coalescing pipeline have separate ownership so + /// alternate event transports can reuse the same ordering, reset, buffering, + /// and lifecycle behavior. + init( + watchedPaths: [String], + eventSource: any FileWatchEventSource, + clock: any FileWatchClock = SystemFileWatchClock() + ) { + self.watchedPaths = watchedPaths self.clock = clock - let (events, eventsContinuation) = AsyncStream.makeStream() + self.eventSource = eventSource + let (events, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) self.events = events - self.continuation = eventsContinuation - let (rawEvents, rawContinuation) = AsyncStream.makeStream() - self.rawContinuation = rawContinuation - - // The sink captures `rawContinuation` (a Sendable value), not `self`, so - // the stream can be created synchronously here without escaping the - // actor mid-init. - guard let eventStream = FileSystemEventStream( - paths: paths, - latency: Self.streamLatency, - onEvent: { rawContinuation.yield(()) } - ) else { - eventsContinuation.finish() - rawContinuation.finish() - return nil - } - self.eventStream = eventStream + self.continuation = continuation + let rawEvents = eventSource.events // Drain raw FS events through the actor-isolated throttle. Started last so // init touches no isolated state after `self` escapes into the task; it // holds `self` weakly and ends when `rawEvents` finishes (stop/deinit). Task { [weak self] in - for await _ in rawEvents { - await self?.handleRawEvent() + for await identity in rawEvents { + await self?.handleRawEvent(identity: identity) } } } - /// Creates a watcher with no underlying `FSEventStream`, driven only by - /// ``simulateFileSystemEventForTesting()``. - /// - /// Used by the package tests to exercise the coalescing throttle in isolation - /// with an injected clock and no real filesystem dependency. - init(testThrottleClock clock: any FileWatchClock) { - self.watchedPaths = [] - self.clock = clock - let (events, eventsContinuation) = AsyncStream.makeStream() - self.events = events - self.continuation = eventsContinuation - let (_, rawContinuation) = AsyncStream.makeStream() - self.rawContinuation = rawContinuation - self.eventStream = nil - } - /// Stops the watcher, tears down the underlying stream, and finishes /// ``events``. Idempotent. public func stop() { isStopped = true throttleTask?.cancel() throttleTask = nil - eventStream?.stop() - rawContinuation.finish() + eventSource.stop() continuation.finish() } deinit { // FSEventStream teardown is synchronous and thread-safe; finishing the // continuations ends the pump and any consumer. - eventStream?.stop() + eventSource.stop() throttleTask?.cancel() - rawContinuation.finish() continuation.finish() } /// Leading-edge throttle entry point. The first event of a window arms one /// delay; events arriving while it is pending are no-ops (the `throttleTask /// == nil` guard), so a burst yields a single ``events`` element. - private func handleRawEvent() { - guard !isStopped, throttleTask == nil else { return } + private func handleRawEvent(identity: FileWatchEventIdentity) { + guard !isStopped else { return } + guard coalescingState.record(identity) else { return } let clock = self.clock let interval = Self.throttleInterval throttleTask = Task { [weak self] in @@ -156,13 +213,8 @@ public actor RecursivePathWatcher { private func flushThrottle() { throttleTask = nil - guard !isStopped else { return } - continuation.yield(()) - } - - /// Feeds a synthetic filesystem event into the throttle. Test-only seam used - /// by ``init(testThrottleClock:)``-constructed watchers. - func simulateFileSystemEventForTesting() { - handleRawEvent() + guard !isStopped, let identity = coalescingState.takePendingIdentity() else { return } + let result = continuation.yield(identity) + coalescingState.recordYield(result) } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c new file mode 100644 index 000000000000..fb85c73bc1ee --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/CmuxFoundationAtomicsC.c @@ -0,0 +1,17 @@ +#include "CmuxFoundationAtomicsC.h" + +void CmuxAtomicBooleanInitialize(CmuxAtomicBooleanStorage *storage, bool initialValue) { + atomic_init(&storage->value, initialValue); +} + +bool CmuxAtomicBooleanLoadRelaxed(const CmuxAtomicBooleanStorage *storage) { + return atomic_load_explicit(&storage->value, memory_order_relaxed); +} + +bool CmuxAtomicBooleanLoadAcquire(const CmuxAtomicBooleanStorage *storage) { + return atomic_load_explicit(&storage->value, memory_order_acquire); +} + +void CmuxAtomicBooleanStoreRelease(CmuxAtomicBooleanStorage *storage, bool value) { + atomic_store_explicit(&storage->value, value, memory_order_release); +} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h new file mode 100644 index 000000000000..e1b62df60efb --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundationAtomicsC/include/CmuxFoundationAtomicsC.h @@ -0,0 +1,16 @@ +#ifndef CMUX_FOUNDATION_ATOMICS_C_H +#define CMUX_FOUNDATION_ATOMICS_C_H + +#include +#include + +typedef struct { + atomic_bool value; +} CmuxAtomicBooleanStorage; + +void CmuxAtomicBooleanInitialize(CmuxAtomicBooleanStorage *storage, bool initialValue); +bool CmuxAtomicBooleanLoadRelaxed(const CmuxAtomicBooleanStorage *storage); +bool CmuxAtomicBooleanLoadAcquire(const CmuxAtomicBooleanStorage *storage); +void CmuxAtomicBooleanStoreRelease(CmuxAtomicBooleanStorage *storage, bool value); + +#endif diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicBooleanGateTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicBooleanGateTests.swift new file mode 100644 index 000000000000..52a036b4df35 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/AtomicBooleanGateTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import CmuxFoundation + +@Suite +struct AtomicBooleanGateTests { + @Test func publishesTransitionsAcrossConcurrentReaders() async { + let gate = AtomicBooleanGate(false) + #expect(!gate.loadRelaxed()) + + gate.storeRelease(true) + let observations = await withTaskGroup(of: Bool.self, returning: [Bool].self) { group in + for _ in 0..<100 { + group.addTask { gate.loadRelaxed() } + } + return await group.reduce(into: []) { $0.append($1) } + } + + #expect(observations.allSatisfy { $0 }) + gate.storeRelease(false) + #expect(!gate.loadRelaxed()) + } + + @Test func concurrentReadersAndWritersShareStableAtomicStorage() async { + let gate = AtomicBooleanGate(false) + + await withTaskGroup(of: Void.self) { group in + for writer in 0..<8 { + group.addTask { + for iteration in 0..<10_000 { + gate.storeRelease((writer + iteration).isMultiple(of: 2)) + } + } + } + for _ in 0..<32 { + group.addTask { + for _ in 0..<10_000 { + _ = gate.loadRelaxed() + } + } + } + } + + gate.storeRelease(true) + #expect(gate.loadRelaxed()) + } + + @Test func acquireReaderObservesReleasePublishedActivation() async { + let gate = AtomicBooleanGate(false) + gate.storeRelease(true) + + let observed = await Task.detached { + gate.loadAcquire() + }.value + + #expect(observed) + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/LatestWinsBatcherTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/LatestWinsBatcherTests.swift new file mode 100644 index 000000000000..139ec246229c --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/LatestWinsBatcherTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +@testable import CmuxFoundation + +@MainActor +@Suite struct LatestWinsBatcherTests { + @Test func burstDrainsLatestValueForEachKeyAfterQuietDeadline() { + let scheduler = ManualDeadlineScheduler() + let batcher = LatestWinsBatcher( + quietDelay: 0.05, + maximumDelay: 0.2, + scheduler: scheduler.schedule + ) + var drained: [[String: Int]] = [] + + batcher.submit(1, for: "workspace") { drained.append($0) } + batcher.submit(2, for: "workspace") { drained.append($0) } + batcher.submit(3, for: "history") { drained.append($0) } + + #expect(drained.isEmpty) + scheduler.advance(by: 0.049) + #expect(drained.isEmpty) + scheduler.advance(by: 0.001) + #expect(drained == [["workspace": 2, "history": 3]]) + } + + @Test func continuousBurstDrainsAtMaximumDeadline() { + let scheduler = ManualDeadlineScheduler() + let batcher = LatestWinsBatcher( + quietDelay: 0.1, + maximumDelay: 0.25, + scheduler: scheduler.schedule + ) + var drained: [[String: Int]] = [] + + batcher.submit(1, for: "value") { drained.append($0) } + scheduler.advance(by: 0.08) + batcher.submit(2, for: "value") { drained.append($0) } + scheduler.advance(by: 0.08) + batcher.submit(3, for: "value") { drained.append($0) } + scheduler.advance(by: 0.08) + batcher.submit(4, for: "value") { drained.append($0) } + + #expect(drained.isEmpty) + scheduler.advance(by: 0.01) + #expect(drained == [["value": 4]]) + } + + @Test func cancellationAndEmptyFlushAreNoOps() { + let scheduler = ManualDeadlineScheduler() + let batcher = LatestWinsBatcher( + quietDelay: 0.05, + maximumDelay: 0.2, + scheduler: scheduler.schedule + ) + var drainCount = 0 + + batcher.submit(1, for: "value") { _ in drainCount += 1 } + batcher.cancel() + scheduler.advance(by: 1) + batcher.flushNow { _ in drainCount += 1 } + + #expect(drainCount == 0) + } + + @Test func delayedTaskStartupDoesNotRestartConfiguredDelay() async { + let clock = DeferredDeadlineClock() + let submittedAt = clock.now() + let scheduler = LatestWinsBatcher.absoluteDeadlineScheduler( + now: clock.now, + sleepUntil: clock.sleep, + startTask: clock.startTask + ) + var fireCount = 0 + + _ = scheduler(0.25) { + fireCount += 1 + } + + #expect(clock.pendingTaskCount == 1) + #expect(clock.requestedDeadlines.isEmpty) + + // Model a blocked main actor: the scheduling task cannot start until + // after the original deadline has already elapsed. + clock.advance(by: 0.5) + await clock.runPendingTask() + + #expect(clock.requestedDeadlines == [ + submittedAt.advanced(by: .seconds(0.25)) + ]) + #expect(clock.remainingDurationsAtSleep.count == 1) + #expect(clock.remainingDurationsAtSleep[0] <= .zero) + #expect(fireCount == 1) + } +} + +@MainActor +private final class ManualDeadlineScheduler { + private struct Scheduled { + let id: Int + let deadline: TimeInterval + let action: @MainActor () -> Void + } + + private var now: TimeInterval = 0 + private var nextID = 0 + private var scheduled: [Scheduled] = [] + private var cancelled: Set = [] + + func schedule( + delay: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> LatestWinsBatcher.Cancellation { + nextID += 1 + let id = nextID + scheduled.append(Scheduled(id: id, deadline: now + delay, action: action)) + return { [weak self] in + self?.cancelled.insert(id) + } + } + + func advance(by delta: TimeInterval) { + now += delta + while let next = scheduled + .filter({ !cancelled.contains($0.id) && $0.deadline <= now }) + .min(by: { $0.deadline < $1.deadline }) { + scheduled.removeAll { $0.id == next.id } + next.action() + } + } +} + +@MainActor +private final class DeferredDeadlineClock { + private enum SleepError: Error { + case deadlineStillInFuture + } + + private var current = ContinuousClock().now + private var pendingTask: (@MainActor () async -> Void)? + private(set) var requestedDeadlines: [ContinuousClock.Instant] = [] + private(set) var remainingDurationsAtSleep: [Duration] = [] + + var pendingTaskCount: Int { + pendingTask == nil ? 0 : 1 + } + + func now() -> ContinuousClock.Instant { + current + } + + func advance(by interval: TimeInterval) { + current = current.advanced(by: .seconds(max(0, interval))) + } + + func sleep(until deadline: ContinuousClock.Instant) async throws { + requestedDeadlines.append(deadline) + let remaining = current.duration(to: deadline) + remainingDurationsAtSleep.append(remaining) + if remaining > .zero { + throw SleepError.deadlineStillInFuture + } + } + + func startTask( + operation: @escaping @MainActor () async -> Void + ) -> LatestWinsBatcher.Cancellation { + precondition(pendingTask == nil) + pendingTask = operation + return { [weak self] in + self?.pendingTask = nil + } + } + + func runPendingTask() async { + let operation = pendingTask + pendingTask = nil + await operation?() + } +} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift index 6c5a05ae0f10..37765d47ba1b 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/FileWatch/RecursivePathWatcherTests.swift @@ -1,3 +1,4 @@ +import CoreServices import Foundation import Testing @@ -36,6 +37,33 @@ private actor GateClock: FileWatchClock { } } +private final class ManualFileWatchEventSource: FileWatchEventSource, @unchecked Sendable { + let events: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + (events, continuation) = AsyncStream.makeStream() + } + + func send(_ identity: FileWatchEventIdentity) { + continuation.yield(identity) + } + + func stop() { + continuation.finish() + } +} + +@discardableResult +private func yieldPendingIdentity( + from state: inout FileWatchEventCoalescingState, + into continuation: AsyncStream.Continuation +) -> FileWatchEventIdentity? { + guard let identity = state.takePendingIdentity() else { return nil } + state.recordYield(continuation.yield(identity)) + return identity +} + @Suite struct RecursivePathWatcherTests { @Test func emptyPathsFailsInitialization() { let watcher = RecursivePathWatcher(paths: []) @@ -54,53 +82,166 @@ private actor GateClock: FileWatchClock { await watcher?.stop() } - /// A burst of events inside one throttle window coalesces into a single - /// yield, a fresh event re-arms the throttle, and `stop()` finishes the - /// stream. This is the leading-edge behavior the watcher provides: react once - /// per window during a storm, never once per event and never only after - /// changes stop. - @Test func burstCoalescesAndThrottleRearms() async { + @Test func burstCoalescingStateArmsOnceAndRearmsAfterFlush() { + var state = FileWatchEventCoalescingState() + + let didArmFirstWindow = state.record(.stable(FileWatchEventID(rawValue: 1))) + #expect(didArmFirstWindow) + for rawID in 2...5 { + let didRearm = state.record(.stable(FileWatchEventID(rawValue: UInt64(rawID)))) + #expect(!didRearm) + } + let firstWindowIdentity = state.takePendingIdentity() + let didArmSecondWindow = state.record(.stable(FileWatchEventID(rawValue: 6))) + #expect(firstWindowIdentity == .stable(FileWatchEventID(rawValue: 5))) + #expect(didArmSecondWindow) + } + + /// The transport pump, virtual clock, and output stream share the same + /// production path as FSEvents. A completed window must permit a later event + /// to arm a fresh delay. + @Test func sourceEventsFlowThroughThrottleAndRearm() async { let clock = GateClock() - let watcher = RecursivePathWatcher(testThrottleClock: clock) + let source = ManualFileWatchEventSource() + let watcher = RecursivePathWatcher( + watchedPaths: ["/manual"], + eventSource: source, + clock: clock + ) var iterator = watcher.events.makeAsyncIterator() - // Window 1: five events, but only the first arms the throttle. - for _ in 0..<5 { - await watcher.simulateFileSystemEventForTesting() - } + source.send(.stable(FileWatchEventID(rawValue: 1))) await clock.waitForSleeper() #expect(await clock.sleeperCount == 1) - await clock.releaseOne() - let first: Void? = await iterator.next() - #expect(first != nil) + #expect(await iterator.next() == .stable(FileWatchEventID(rawValue: 1))) - // Window 2: the throttle re-arms after the previous flush. - for _ in 0..<3 { - await watcher.simulateFileSystemEventForTesting() - } + source.send(.stable(FileWatchEventID(rawValue: 2))) await clock.waitForSleeper() #expect(await clock.sleeperCount == 1) - await clock.releaseOne() - let second: Void? = await iterator.next() - #expect(second != nil) + #expect(await iterator.next() == .stable(FileWatchEventID(rawValue: 2))) await watcher.stop() - let afterStop: Void? = await iterator.next() - #expect(afterStop == nil) + #expect(await iterator.next() == nil) } /// Events delivered after `stop()` produce no further yields. @Test func eventsAfterStopAreIgnored() async { let clock = GateClock() - let watcher = RecursivePathWatcher(testThrottleClock: clock) + let source = ManualFileWatchEventSource() + let watcher = RecursivePathWatcher( + watchedPaths: ["/manual"], + eventSource: source, + clock: clock + ) var iterator = watcher.events.makeAsyncIterator() await watcher.stop() - await watcher.simulateFileSystemEventForTesting() - let next: Void? = await iterator.next() + source.send(.stable(FileWatchEventID(rawValue: 1))) + let next: FileWatchEventIdentity? = await iterator.next() #expect(next == nil) #expect(await clock.sleeperCount == 0) } + + @Test func eventsEmitLatestStableIDFromThrottleWindow() { + var state = FileWatchEventCoalescingState() + + _ = state.record(.stable(FileWatchEventID(rawValue: 41))) + _ = state.record(.stable(FileWatchEventID(rawValue: 43))) + _ = state.record(.stable(FileWatchEventID(rawValue: 42))) + + let identity = state.takePendingIdentity() + #expect(identity == .stable(FileWatchEventID(rawValue: 43))) + } + + @Test func droppedAndWrappedBatchesStayConservative() async { + let userDropped = FileSystemEventStream.eventIdentity( + latestEventID: 100, + flags: FSEventStreamEventFlags(kFSEventStreamEventFlagUserDropped) + ) + let kernelDropped = FileSystemEventStream.eventIdentity( + latestEventID: 101, + flags: FSEventStreamEventFlags(kFSEventStreamEventFlagKernelDropped) + ) + let mustScan = FileSystemEventStream.eventIdentity( + latestEventID: 102, + flags: FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs) + ) + let wrapped = FileSystemEventStream.eventIdentity( + latestEventID: 1, + flags: FSEventStreamEventFlags(kFSEventStreamEventFlagEventIdsWrapped) + ) + + #expect(userDropped == .mustRescan) + #expect(kernelDropped == .mustRescan) + #expect(mustScan == .mustRescan) + #expect(wrapped == .eventIDsWrapped) + } + + @Test func conservativeIdentityDominatesOnlyUntilBufferedResetIsObserved() async { + var state = FileWatchEventCoalescingState() + let (events, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + var iterator = events.makeAsyncIterator() + + _ = state.record(.stable(FileWatchEventID(rawValue: 50))) + _ = state.record(.mustRescan) + _ = state.record(.stable(FileWatchEventID(rawValue: 51))) + let firstReset = yieldPendingIdentity(from: &state, into: continuation) + #expect(firstReset == .mustRescan) + #expect(await iterator.next() == .mustRescan) + + _ = state.record(.stable(FileWatchEventID(rawValue: 52))) + let repeatedReset = yieldPendingIdentity(from: &state, into: continuation) + #expect(repeatedReset == .mustRescan) + // Repeat the conservative signal once after the consumer drains it. This + // acknowledges the bounded AsyncStream buffer without losing a reset to + // a newer stable watermark. + #expect(await iterator.next() == .mustRescan) + + _ = state.record(.stable(FileWatchEventID(rawValue: 53))) + let resumedStableIdentity = yieldPendingIdentity(from: &state, into: continuation) + #expect(resumedStableIdentity == .stable(FileWatchEventID(rawValue: 53))) + #expect(await iterator.next() == .stable(FileWatchEventID(rawValue: 53))) + + _ = state.record(.stable(FileWatchEventID(rawValue: 54))) + _ = state.record(.eventIDsWrapped) + let wrappedReset = yieldPendingIdentity(from: &state, into: continuation) + #expect(wrappedReset == .eventIDsWrapped) + #expect(await iterator.next() == .eventIDsWrapped) + + _ = state.record(.stable(FileWatchEventID(rawValue: 1))) + let repeatedWrappedReset = yieldPendingIdentity(from: &state, into: continuation) + #expect(repeatedWrappedReset == .eventIDsWrapped) + #expect(await iterator.next() == .eventIDsWrapped) + + _ = state.record(.stable(FileWatchEventID(rawValue: 2))) + let resumedWrappedStableIdentity = yieldPendingIdentity( + from: &state, + into: continuation + ) + #expect(resumedWrappedStableIdentity == .stable(FileWatchEventID(rawValue: 2))) + #expect(await iterator.next() == .stable(FileWatchEventID(rawValue: 2))) + continuation.finish() + #expect(await iterator.next() == nil) + } + + @Test func unconsumedStreamRetainsOnlyNewestStableWatermark() async { + var state = FileWatchEventCoalescingState() + let (events, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + + for rawID in 1...100 { + _ = state.record(.stable(FileWatchEventID(rawValue: UInt64(rawID)))) + _ = yieldPendingIdentity(from: &state, into: continuation) + } + + var iterator = events.makeAsyncIterator() + #expect(await iterator.next() == .stable(FileWatchEventID(rawValue: 100))) + continuation.finish() + #expect(await iterator.next() == nil) + } } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCache.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCache.swift index 6c71092e32a4..346c79d00904 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCache.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCache.swift @@ -1,48 +1,243 @@ import Foundation /// Bounded cache of tracked-change scans keyed by repository, index stat, and -/// namespaced caller-owned filesystem-event generation. -actor GitTrackedChangesSnapshotCache { +/// namespaced caller-owned filesystem-event generation. Sharing one instance +/// also coalesces concurrent cache misses for the same key into one scan. +public actor GitTrackedChangesSnapshotCache { + /// Cancellation must become visible synchronously in the caller's + /// cancellation handler. The actor cleanup task can lose a race with the + /// detached load's completion, so this small lock-protected token is the + /// linearization point for cancellation versus result delivery. + private final class Waiter: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var result: GitTrackedChangesSnapshot? + private var isResolved = false + + func value() async -> GitTrackedChangesSnapshot? { + await withCheckedContinuation { continuation in + lock.lock() + if isResolved { + let result = result + lock.unlock() + continuation.resume(returning: result) + } else { + self.continuation = continuation + lock.unlock() + } + } + } + + @discardableResult + func cancel() -> Bool { + resolve(with: nil) + } + + @discardableResult + func complete(with snapshot: GitTrackedChangesSnapshot) -> Bool { + resolve(with: snapshot) + } + + private func resolve(with result: GitTrackedChangesSnapshot?) -> Bool { + lock.lock() + guard !isResolved else { + lock.unlock() + return false + } + isResolved = true + self.result = result + let continuation = continuation + self.continuation = nil + lock.unlock() + continuation?.resume(returning: result) + return true + } + } + + private struct InFlightSnapshot { + let id: UUID + let task: Task + var waitersByID: [UUID: Waiter] + } + private let maximumEntryCount: Int + nonisolated let runtimeMetricsRecorder: CmuxGitRuntimeMetrics + private let diagnosticGate: GitTrackedChangesSnapshotDiagnosticGate? private var entriesByKey: [ GitTrackedChangesSnapshotCacheKey: GitTrackedChangesSnapshotCacheEntry ] = [:] private var insertionOrder: [GitTrackedChangesSnapshotCacheKey] = [] + private var inFlightSnapshotsByKey: [ + GitTrackedChangesSnapshotCacheKey: InFlightSnapshot + ] = [:] - init(maximumEntryCount: Int = 256) { + /// Creates an injectable tracked-snapshot coordination scope. + /// + /// - Parameter maximumEntryCount: Maximum completed snapshots retained. + public init(maximumEntryCount: Int = 256) { self.maximumEntryCount = max(1, maximumEntryCount) + self.runtimeMetricsRecorder = GitMetadataService.runtimeMetrics + self.diagnosticGate = nil + } + + init( + maximumEntryCount: Int = 256, + runtimeMetricsRecorder: CmuxGitRuntimeMetrics, + diagnosticGate: GitTrackedChangesSnapshotDiagnosticGate? = nil + ) { + self.maximumEntryCount = max(1, maximumEntryCount) + self.runtimeMetricsRecorder = runtimeMetricsRecorder + self.diagnosticGate = diagnosticGate } func snapshot( repository: ResolvedGitRepository, indexStatSignature: GitIndexStatSignature, - trackedPathEventGeneration: GitTrackedPathEventGeneration + authority: GitTrackedChangesSnapshotAuthority ) -> GitTrackedChangesSnapshot? { let key = GitTrackedChangesSnapshotCacheKey( repository: repository, indexStatSignature: indexStatSignature, - trackedPathEventGeneration: trackedPathEventGeneration + authority: authority ) - return entriesByKey[key]?.snapshot + guard let snapshot = entriesByKey[key]?.snapshot else { return nil } + runtimeMetricsRecorder.recordTrackedStatusCacheHit() + return snapshot + } + + func snapshot( + repository: ResolvedGitRepository, + indexStatSignature: GitIndexStatSignature, + authority: GitTrackedChangesSnapshotAuthority, + load: @escaping @Sendable () -> GitTrackedChangesSnapshot + ) async -> GitTrackedChangesSnapshot? { + guard !Task.isCancelled else { return nil } + runtimeMetricsRecorder.recordTrackedStatusRequest() + let key = GitTrackedChangesSnapshotCacheKey( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority + ) + if let snapshot = entriesByKey[key]?.snapshot { + runtimeMetricsRecorder.recordTrackedStatusCacheHit() + return snapshot + } + + let waiterID = UUID() + let waiter = Waiter() + registerWaiter( + waiterID, + waiter: waiter, + key: key, + load: load + ) + let snapshot = await withTaskCancellationHandler { + await waiter.value() + } onCancel: { + waiter.cancel() + } + if snapshot == nil { + removeCanceledWaiter(waiterID, key: key) + } + return snapshot } func store( _ snapshot: GitTrackedChangesSnapshot, repository: ResolvedGitRepository, indexStatSignature: GitIndexStatSignature, - trackedPathEventGeneration: GitTrackedPathEventGeneration + authority: GitTrackedChangesSnapshotAuthority ) { let key = GitTrackedChangesSnapshotCacheKey( repository: repository, indexStatSignature: indexStatSignature, - trackedPathEventGeneration: trackedPathEventGeneration + authority: authority ) + store(snapshot, for: key) + } + + private func store( + _ snapshot: GitTrackedChangesSnapshot, + for key: GitTrackedChangesSnapshotCacheKey + ) { insertionOrder.removeAll { $0 == key } insertionOrder.append(key) entriesByKey[key] = GitTrackedChangesSnapshotCacheEntry(snapshot: snapshot) evictOldestEntriesIfNeeded() } + private func registerWaiter( + _ waiterID: UUID, + waiter: Waiter, + key: GitTrackedChangesSnapshotCacheKey, + load: @escaping @Sendable () -> GitTrackedChangesSnapshot + ) { + guard !Task.isCancelled else { + waiter.cancel() + return + } + if var inFlight = inFlightSnapshotsByKey[key] { + runtimeMetricsRecorder.recordTrackedStatusInFlightJoin() + inFlight.waitersByID[waiterID] = waiter + inFlightSnapshotsByKey[key] = inFlight + diagnosticGate?.recordRegisteredWaiterCount(inFlight.waitersByID.count) + return + } + + let operationID = UUID() + let diagnosticGate = diagnosticGate + let task = Task.detached(priority: Task.currentPriority) { + diagnosticGate?.waitForExpectedWaiters() + return load() + } + inFlightSnapshotsByKey[key] = InFlightSnapshot( + id: operationID, + task: task, + waitersByID: [waiterID: waiter] + ) + diagnosticGate?.recordRegisteredWaiterCount(1) + Task { [weak self] in + let snapshot = await task.value + await self?.completeOperation( + operationID, + snapshot: snapshot, + key: key + ) + } + } + + private func removeCanceledWaiter( + _ waiterID: UUID, + key: GitTrackedChangesSnapshotCacheKey + ) { + guard var inFlight = inFlightSnapshotsByKey[key], + let waiter = inFlight.waitersByID.removeValue(forKey: waiterID) else { return } + waiter.cancel() + if inFlight.waitersByID.isEmpty { + inFlightSnapshotsByKey.removeValue(forKey: key) + inFlight.task.cancel() + } else { + inFlightSnapshotsByKey[key] = inFlight + } + } + + private func completeOperation( + _ operationID: UUID, + snapshot: GitTrackedChangesSnapshot, + key: GitTrackedChangesSnapshotCacheKey + ) { + guard let inFlight = inFlightSnapshotsByKey[key], + inFlight.id == operationID else { return } + inFlightSnapshotsByKey.removeValue(forKey: key) + var deliveredResult = false + for waiter in inFlight.waitersByID.values { + deliveredResult = waiter.complete(with: snapshot) || deliveredResult + } + if deliveredResult { + store(snapshot, for: key) + } + } + private func evictOldestEntriesIfNeeded() { while entriesByKey.count > maximumEntryCount, let oldest = insertionOrder.first { diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCacheKey.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCacheKey.swift index 000ab3326532..755775dfe599 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCacheKey.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotCacheKey.swift @@ -4,15 +4,15 @@ import Foundation struct GitTrackedChangesSnapshotCacheKey: Equatable, Hashable, Sendable { let repository: GitTrackedChangesSnapshotRepositoryKey let indexStatSignature: GitIndexStatSignature - let trackedPathEventGeneration: GitTrackedPathEventGeneration + let authority: GitTrackedChangesSnapshotAuthority init( repository: ResolvedGitRepository, indexStatSignature: GitIndexStatSignature, - trackedPathEventGeneration: GitTrackedPathEventGeneration + authority: GitTrackedChangesSnapshotAuthority ) { self.repository = GitTrackedChangesSnapshotRepositoryKey(repository: repository) self.indexStatSignature = indexStatSignature - self.trackedPathEventGeneration = trackedPathEventGeneration + self.authority = authority } } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotScope.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotScope.swift new file mode 100644 index 000000000000..d6bbad42d7b2 --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Cache/GitTrackedChangesSnapshotScope.swift @@ -0,0 +1,200 @@ +import Foundation + +/// Bounded process scope for repository revision authority and tracked scans. +/// +/// Filesystem watchers advance a repository revision here before scheduling +/// work. Fallback requests carry the coordinator's explicit global round ID. +/// Snapshot loads use the stamped immutable authority after they pass through +/// the process limiter, so queued work cannot accidentally adopt a later round. +public actor GitTrackedChangesSnapshotScope { + private struct RepositoryState { + var epoch = UUID() + var revision: UInt64 = 0 + var lastStableWatcherEventID: GitTrackedPathEventID? + } + + private let snapshotCache: GitTrackedChangesSnapshotCache + nonisolated let runtimeMetricsRecorder: CmuxGitRuntimeMetrics + private let maximumRepositoryCount: Int + private var repositoryStates: [ + GitTrackedChangesRepositoryIdentity: RepositoryState + ] = [:] + private var repositoryInsertionOrder: [GitTrackedChangesRepositoryIdentity] = [] + + /// Creates a bounded, injectable coordination scope. + public init( + maximumSnapshotCount: Int = 256, + maximumRepositoryCount: Int = 256 + ) { + let runtimeMetricsRecorder = GitMetadataService.runtimeMetrics + self.runtimeMetricsRecorder = runtimeMetricsRecorder + self.snapshotCache = GitTrackedChangesSnapshotCache( + maximumEntryCount: maximumSnapshotCount, + runtimeMetricsRecorder: runtimeMetricsRecorder + ) + self.maximumRepositoryCount = max(1, maximumRepositoryCount) + } + + init( + snapshotCache: GitTrackedChangesSnapshotCache, + maximumRepositoryCount: Int = 256 + ) { + self.snapshotCache = snapshotCache + self.runtimeMetricsRecorder = snapshotCache.runtimeMetricsRecorder + self.maximumRepositoryCount = max(1, maximumRepositoryCount) + } + + init( + maximumSnapshotCount: Int = 256, + maximumRepositoryCount: Int = 256, + runtimeMetricsRecorder: CmuxGitRuntimeMetrics, + diagnosticGate: GitTrackedChangesSnapshotDiagnosticGate? = nil + ) { + self.runtimeMetricsRecorder = runtimeMetricsRecorder + self.snapshotCache = GitTrackedChangesSnapshotCache( + maximumEntryCount: maximumSnapshotCount, + runtimeMetricsRecorder: runtimeMetricsRecorder, + diagnosticGate: diagnosticGate + ) + self.maximumRepositoryCount = max(1, maximumRepositoryCount) + } + + /// Returns the current repository authority for a fallback round. + public func authority( + for repositoryIdentity: GitTrackedChangesRepositoryIdentity, + fallbackRoundID: GitFallbackRoundID? + ) -> GitTrackedChangesSnapshotAuthority { + let state = state(for: repositoryIdentity) + return GitTrackedChangesSnapshotAuthority( + repositoryIdentity: repositoryIdentity, + repositoryEpoch: state.epoch, + repositoryRevision: state.revision, + fallbackRoundID: fallbackRoundID + ) + } + + /// Advances repository revision before watcher-triggered work is queued. + @discardableResult + public func recordWatcherEvent( + for repositoryIdentity: GitTrackedChangesRepositoryIdentity, + source: GitTrackedPathEventSource = .unknown + ) -> GitTrackedChangesSnapshotAuthority { + var state = state(for: repositoryIdentity) + switch source { + case .stable(let eventID): + if let lastEventID = state.lastStableWatcherEventID, + !eventID.isNewer(than: lastEventID) { + return snapshotAuthority( + repositoryIdentity: repositoryIdentity, + state: state, + fallbackRoundID: nil + ) + } + state.lastStableWatcherEventID = eventID + case .unknown: + break + case .sequenceReset: + // The reset signal advances revision. Preserve the last stable ID: + // serial-number ordering accepts the wrapped low sequence while + // rejecting delayed events from before the wrap. + break + } + if state.revision == .max { + state.epoch = UUID() + state.revision = 0 + } else { + state.revision += 1 + } + store(state, for: repositoryIdentity) + return snapshotAuthority( + repositoryIdentity: repositoryIdentity, + state: state, + fallbackRoundID: nil + ) + } + + func snapshot( + repository: ResolvedGitRepository, + indexStatSignature: GitIndexStatSignature, + authority: GitTrackedChangesSnapshotAuthority, + load: @escaping @Sendable () -> GitTrackedChangesSnapshot + ) async -> GitTrackedChangesSnapshotRead? { + guard authority.repositoryIdentity.matches(repository) else { + let snapshot = await Task.detached(priority: Task.currentPriority) { + load() + }.value + return GitTrackedChangesSnapshotRead(snapshot: snapshot, isCurrent: false) + } + guard let snapshot = await snapshotCache.snapshot( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority, + load: load + ) else { return nil } + return validate( + snapshot: snapshot, + repository: repository, + authority: authority + ) + } + + /// Validates an uncached scan without running blocking filesystem work on + /// this actor. The caller computes `snapshot` first, then this actor compares + /// the stamped epoch/revision with current repository authority. + func validate( + snapshot: GitTrackedChangesSnapshot, + repository: ResolvedGitRepository, + authority: GitTrackedChangesSnapshotAuthority + ) -> GitTrackedChangesSnapshotRead { + guard authority.repositoryIdentity.matches(repository) else { + return GitTrackedChangesSnapshotRead(snapshot: snapshot, isCurrent: false) + } + let currentState = repositoryStates[authority.repositoryIdentity] + let isCurrent = currentState?.epoch == authority.repositoryEpoch + && currentState?.revision == authority.repositoryRevision + return GitTrackedChangesSnapshotRead(snapshot: snapshot, isCurrent: isCurrent) + } + + private func state( + for identity: GitTrackedChangesRepositoryIdentity + ) -> RepositoryState { + if let state = repositoryStates[identity] { + touch(identity) + return state + } + let state = RepositoryState() + store(state, for: identity) + return state + } + + private func snapshotAuthority( + repositoryIdentity: GitTrackedChangesRepositoryIdentity, + state: RepositoryState, + fallbackRoundID: GitFallbackRoundID? + ) -> GitTrackedChangesSnapshotAuthority { + GitTrackedChangesSnapshotAuthority( + repositoryIdentity: repositoryIdentity, + repositoryEpoch: state.epoch, + repositoryRevision: state.revision, + fallbackRoundID: fallbackRoundID + ) + } + + private func store( + _ state: RepositoryState, + for identity: GitTrackedChangesRepositoryIdentity + ) { + touch(identity) + repositoryStates[identity] = state + while repositoryStates.count > maximumRepositoryCount, + let oldest = repositoryInsertionOrder.first { + repositoryInsertionOrder.removeFirst() + repositoryStates.removeValue(forKey: oldest) + } + } + + private func touch(_ identity: GitTrackedChangesRepositoryIdentity) { + repositoryInsertionOrder.removeAll { $0 == identity } + repositoryInsertionOrder.append(identity) + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Diagnostics/GitOwnerPerformanceExercise.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Diagnostics/GitOwnerPerformanceExercise.swift new file mode 100644 index 000000000000..b76d517cf41b --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Diagnostics/GitOwnerPerformanceExercise.swift @@ -0,0 +1,149 @@ +import Foundation + +/// Behavioral result from the isolated Git ownership exercise. +/// +/// Counter values are deliberately absent. Callers must cross-check the +/// process runtime metrics, whose values arise at the normal cache and scan +/// record sites. +public struct GitOwnerPerformanceExerciseResult: Sendable { + public let requestCount: Int + public let completedSnapshotCount: Int + public let allSnapshotsMatched: Bool + public let allWaitersRegistered: Bool +} + +/// Runs the production tracked-status cache path against an isolated temporary +/// repository. It does not inspect user repositories or spawn a process. +public struct GitOwnerPerformanceExercise: Sendable { + private let runtimeMetricsRecorder: CmuxGitRuntimeMetrics + + /// Creates an exercise that records through the production metrics owner. + public init() { + runtimeMetricsRecorder = GitMetadataService.runtimeMetrics + } + + init(runtimeMetricsRecorder: CmuxGitRuntimeMetrics) { + self.runtimeMetricsRecorder = runtimeMetricsRecorder + } + + public func run( + requestCount: Int + ) async throws -> GitOwnerPerformanceExerciseResult { + guard (2...8).contains(requestCount) else { + throw GitOwnerPerformanceExerciseError.invalidRequestCount + } + let repositoryFixture = try GitOwnerPerformanceRepositoryFixture() + let repository = try repositoryFixture.resolvedRepository() + let gate = GitTrackedChangesSnapshotDiagnosticGate(expectedWaiterCount: requestCount) + let scope = GitTrackedChangesSnapshotScope( + runtimeMetricsRecorder: runtimeMetricsRecorder, + diagnosticGate: gate + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority = await scope.authority(for: identity, fallbackRoundID: nil) + let services = (0.. ResolvedGitRepository { + guard let repository = GitMetadataService.resolveGitRepository(containing: root.path) else { + throw GitOwnerPerformanceExerciseError.repositoryResolutionFailed + } + return repository + } + + private static let emptyIndexData = Data( + Array("DIRC".utf8) + + [0, 0, 0, 2] + + [0, 0, 0, 0] + + Array(repeating: 0, count: 20) + ) +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift index 2c397bf238c4..c902be16f705 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift @@ -9,16 +9,19 @@ import Foundation /// of paths a filesystem watcher should observe to know when that metadata /// becomes stale. /// -/// It is a `Sendable` value facade over blocking filesystem reads plus a small -/// actor-isolated tracked-change cache. The reads do blocking filesystem work +/// It is a `Sendable` value facade over blocking filesystem reads plus an +/// injectable actor-isolated tracked-change scope. Callers that share the scope +/// also share repository refresh authority, completed snapshots, and one +/// in-flight scan per cache key. The +/// reads do blocking filesystem work /// (walking to the repository, parsing the git `index`/`config`), and are plain /// `nonisolated async` methods (a struct's `async` methods are nonisolated): a /// `nonisolated async` function runs on the global concurrent executor, not the /// caller's actor (SE-0338), so `await git.workspaceMetadata(...)` from the main /// actor offloads the work off the main thread *and* lets reads for independent -/// repositories run in parallel. The cache is an actor because it is mutable -/// shared state, but it is only consulted through the watcher-generation API; -/// direct reads without a watcher generation always do a conservative scan. +/// repositories run in parallel. The scope is an actor because it is mutable +/// shared state, but it is only consulted through an explicit watcher-event or +/// fallback-round request; direct reads always do a conservative scan. /// /// - Important: If the package ever adopts the `NonisolatedNonsendingByDefault` /// upcoming feature, a bare `nonisolated async` method flips to running on the @@ -31,21 +34,30 @@ import Foundation /// if meta.isRepository, meta.isDirty { showDirtyIndicator() } /// ``` public struct GitMetadataService: Sendable { + static let runtimeMetrics = CmuxGitRuntimeMetrics() let fileStatusReader: any GitFileStatusReading - private let trackedChangesSnapshotCache: GitTrackedChangesSnapshotCache + private let trackedChangesSnapshotScope: GitTrackedChangesSnapshotScope + let runtimeMetricsRecorder: CmuxGitRuntimeMetrics - /// Creates a git-metadata service. - public init() { + /// Creates a git-metadata service with an injectable process scope. + /// + /// Services coordinate tracked scans only when they receive the same scope. + /// The default creates an isolated bounded scope. + public init( + trackedChangesSnapshotScope: GitTrackedChangesSnapshotScope = GitTrackedChangesSnapshotScope() + ) { self.fileStatusReader = SystemGitFileStatusReader() - self.trackedChangesSnapshotCache = GitTrackedChangesSnapshotCache() + self.trackedChangesSnapshotScope = trackedChangesSnapshotScope + self.runtimeMetricsRecorder = trackedChangesSnapshotScope.runtimeMetricsRecorder } init( fileStatusReader: any GitFileStatusReading, - trackedChangesSnapshotCache: GitTrackedChangesSnapshotCache = GitTrackedChangesSnapshotCache() + trackedChangesSnapshotScope: GitTrackedChangesSnapshotScope = GitTrackedChangesSnapshotScope() ) { self.fileStatusReader = fileStatusReader - self.trackedChangesSnapshotCache = trackedChangesSnapshotCache + self.trackedChangesSnapshotScope = trackedChangesSnapshotScope + self.runtimeMetricsRecorder = trackedChangesSnapshotScope.runtimeMetricsRecorder } /// Reads a point-in-time git snapshot for `directory`. @@ -58,69 +70,130 @@ public struct GitMetadataService: Sendable { /// - Returns: The git metadata for the enclosing repository, or /// ``GitWorkspaceMetadata/notARepository`` when there is none. public nonisolated func workspaceMetadata(for directory: String) async -> GitWorkspaceMetadata { - await workspaceMetadata(for: directory, trackedPathEventGeneration: nil) + await workspaceMetadata(for: directory, snapshotRequest: nil) } - /// Reads a point-in-time git snapshot for `directory`, allowing callers - /// with a repository filesystem-event generation token to enable - /// tracked-change reuse when no relevant event has arrived. - /// - /// - Parameters: - /// - directory: An absolute path to inspect. - /// - trackedPathEventGeneration: A caller-owned, namespaced generation - /// that changes whenever the watched repository paths report a - /// filesystem event. Pass `nil` when no watcher is active; the read - /// then avoids reuse. - /// - Returns: The git metadata for the enclosing repository, or - /// ``GitWorkspaceMetadata/notARepository`` when there is none. + /// Reads a point-in-time snapshot with process-coordinated cache authority. public nonisolated func workspaceMetadata( for directory: String, - trackedPathEventGeneration: GitTrackedPathEventGeneration? + snapshotRequest: GitTrackedChangesSnapshotRequest? ) async -> GitWorkspaceMetadata { + await workspaceMetadataSnapshot( + for: directory, + snapshotRequest: snapshotRequest + ).metadata + } + + /// Reads metadata and reports whether its stamped authority stayed current. + public nonisolated func workspaceMetadataSnapshot( + for directory: String, + snapshotRequest: GitTrackedChangesSnapshotRequest? + ) async -> GitWorkspaceMetadataSnapshot { guard let repository = Self.resolveGitRepository(containing: directory) else { - return .notARepository + return GitWorkspaceMetadataSnapshot( + metadata: .notARepository, + isCurrent: true + ) } - let trackedChanges = await gitTrackedChangesSnapshot( + guard let trackedChangesRead = await gitTrackedChangesSnapshotRead( repository: repository, - trackedPathEventGeneration: trackedPathEventGeneration - ) - return GitWorkspaceMetadata( - isRepository: true, - branch: Self.gitBranchName(repository: repository), - isDirty: trackedChanges.isDirty, - indexSignature: trackedChanges.indexSignature, - indexContentSignature: trackedChanges.indexContentSignature, - headSignature: Self.gitHeadSignature(repository: repository) + snapshotRequest: snapshotRequest + ) else { + return GitWorkspaceMetadataSnapshot( + metadata: .notARepository, + isCurrent: false + ) + } + return GitWorkspaceMetadataSnapshot( + metadata: GitWorkspaceMetadata( + isRepository: true, + branch: Self.gitBranchName(repository: repository), + isDirty: trackedChangesRead.snapshot.isDirty, + indexSignature: trackedChangesRead.snapshot.indexSignature, + indexContentSignature: trackedChangesRead.snapshot.indexContentSignature, + headSignature: Self.gitHeadSignature(repository: repository) + ), + isCurrent: trackedChangesRead.isCurrent ) } nonisolated func gitTrackedChangesSnapshot( repository: ResolvedGitRepository, - trackedPathEventGeneration: GitTrackedPathEventGeneration? + snapshotRequest: GitTrackedChangesSnapshotRequest? ) async -> GitTrackedChangesSnapshot { + if let read = await gitTrackedChangesSnapshotRead( + repository: repository, + snapshotRequest: snapshotRequest + ) { + return read.snapshot + } + return GitTrackedChangesSnapshot( + isDirty: false, + indexSignature: nil, + indexContentSignature: nil + ) + } + + nonisolated func gitTrackedChangesSnapshotRead( + repository: ResolvedGitRepository, + snapshotRequest: GitTrackedChangesSnapshotRequest? + ) async -> GitTrackedChangesSnapshotRead? { let indexURL = URL(fileURLWithPath: repository.gitDirectory).appendingPathComponent("index") - guard let trackedPathEventGeneration, - let indexStatus = fileStatusReader.status(atPath: indexURL.path) else { - return gitTrackedChangesSnapshot(repository: repository) + guard let authority = snapshotRequest?.authority else { + return GitTrackedChangesSnapshotRead( + snapshot: gitTrackedChangesSnapshot(repository: repository), + isCurrent: true + ) + } + guard let indexStatus = fileStatusReader.status(atPath: indexURL.path) else { + let snapshot = gitTrackedChangesSnapshot(repository: repository) + return await trackedChangesSnapshotScope.validate( + snapshot: snapshot, + repository: repository, + authority: authority + ) } let indexStatSignature = indexStatus.indexStatSignature - if let snapshot = await trackedChangesSnapshotCache.snapshot( + return await trackedChangesSnapshotScope.snapshot( repository: repository, indexStatSignature: indexStatSignature, - trackedPathEventGeneration: trackedPathEventGeneration + authority: authority ) { - return snapshot + gitTrackedChangesSnapshot(repository: repository) } + } - let snapshot = gitTrackedChangesSnapshot(repository: repository) - await trackedChangesSnapshotCache.store( - snapshot, - repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: trackedPathEventGeneration + /// Resolves a directory to the stable identity used by snapshot authority. + public nonisolated func trackedChangesRepositoryIdentity( + for directory: String + ) async -> GitTrackedChangesRepositoryIdentity? { + guard let repository = Self.resolveGitRepository(containing: directory) else { + return nil + } + return GitTrackedChangesRepositoryIdentity(repository: repository) + } + + /// Stamps the scope's current repository revision for one fallback round. + public nonisolated func trackedChangesSnapshotAuthority( + for repositoryIdentity: GitTrackedChangesRepositoryIdentity, + fallbackRoundID: GitFallbackRoundID? + ) async -> GitTrackedChangesSnapshotAuthority { + await trackedChangesSnapshotScope.authority( + for: repositoryIdentity, + fallbackRoundID: fallbackRoundID + ) + } + + /// Advances shared repository revision before watcher work is scheduled. + public nonisolated func recordTrackedPathEvent( + for repositoryIdentity: GitTrackedChangesRepositoryIdentity, + sourceEvent: GitTrackedPathEventSource = .unknown + ) async -> GitTrackedChangesSnapshotAuthority { + await trackedChangesSnapshotScope.recordWatcherEvent( + for: repositoryIdentity, + source: sourceEvent ) - return snapshot } /// The set of existing filesystem paths whose changes can alter the metadata diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Metrics/CmuxGitRuntimeMetrics.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Metrics/CmuxGitRuntimeMetrics.swift new file mode 100644 index 000000000000..a6cd96d1ed42 --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Metrics/CmuxGitRuntimeMetrics.swift @@ -0,0 +1,175 @@ +internal import CmuxFoundation +import os + +/// Process-wide counters for the tracked-status scan coordination path. +/// +/// The snapshot contains aggregate counts only. It never records repository +/// paths, cache keys, or other user data, so it is safe to collect in Release +/// builds. Collection defaults off; a disabled record site performs one relaxed +/// atomic load and does not take the counter lock. Enabled counter updates hold +/// an unfair lock only long enough to increment a fixed-width integer. +public struct CmuxGitRuntimeMetricsSnapshot: Codable, Equatable, Sendable { + public let schemaVersion: UInt64 + public let enabled: Bool + public let rawTrackedStatusScanCount: UInt64 + public let trackedStatusCacheHitCount: UInt64 + public let trackedStatusInFlightJoinCount: UInt64 + public let trackedStatusRequestCount: UInt64 + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case enabled + case rawTrackedStatusScanCount + case trackedStatusCacheHitCount + case trackedStatusInFlightJoinCount + case trackedStatusRequestCount + } +} + +/// Instantiated, thread-safe recorder for ``CmuxGitRuntimeMetricsSnapshot``. +/// ``GitMetadataService`` owns the process-wide production instance; tests may +/// inject an independent recorder. +public final class CmuxGitRuntimeMetrics: Sendable { + private let recorder: CmuxGitRuntimeMetricsRecorder + + public init() { + recorder = CmuxGitRuntimeMetricsRecorder() + } + + /// Returns the current counters without changing them. + public func snapshot() -> CmuxGitRuntimeMetricsSnapshot { + recorder.snapshot() + } + + /// Clears all counters and selects whether subsequent events are recorded. + public func reset(enable: Bool) { + recorder.reset(enable: enable) + } + + /// Stops recording while preserving the counters already collected. + public func disable() { + recorder.disable() + } + + /// Returns the current counters and clears them in the same critical section. + public func snapshotAndReset() -> CmuxGitRuntimeMetricsSnapshot { + recorder.snapshotAndReset() + } + + @inline(__always) + func recordRawTrackedStatusScan() { + recorder.recordRawTrackedStatusScan() + } + + @inline(__always) + func recordTrackedStatusCacheHit() { + recorder.recordTrackedStatusCacheHit() + } + + @inline(__always) + func recordTrackedStatusInFlightJoin() { + recorder.recordTrackedStatusInFlightJoin() + } + + @inline(__always) + func recordTrackedStatusRequest() { + recorder.recordTrackedStatusRequest() + } +} + +public extension GitMetadataService { + static func runtimeMetricsSnapshot() -> CmuxGitRuntimeMetricsSnapshot { + runtimeMetrics.snapshot() + } + + static func resetRuntimeMetrics(enable: Bool) { + runtimeMetrics.reset(enable: enable) + } + + static func disableRuntimeMetrics() { + runtimeMetrics.disable() + } +} + +/// An instance-scoped recorder keeps tests independent from live package work. +final class CmuxGitRuntimeMetricsRecorder: Sendable { + private struct State { + var enabled = false + var rawTrackedStatusScanCount: UInt64 = 0 + var trackedStatusCacheHitCount: UInt64 = 0 + var trackedStatusInFlightJoinCount: UInt64 = 0 + var trackedStatusRequestCount: UInt64 = 0 + + var snapshot: CmuxGitRuntimeMetricsSnapshot { + CmuxGitRuntimeMetricsSnapshot( + schemaVersion: 2, + enabled: enabled, + rawTrackedStatusScanCount: rawTrackedStatusScanCount, + trackedStatusCacheHitCount: trackedStatusCacheHitCount, + trackedStatusInFlightJoinCount: trackedStatusInFlightJoinCount, + trackedStatusRequestCount: trackedStatusRequestCount + ) + } + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + private let enabled = AtomicBooleanGate(false) + + func snapshot() -> CmuxGitRuntimeMetricsSnapshot { + state.withLock { $0.snapshot } + } + + func reset(enable: Bool) { + state.withLock { $0 = State(enabled: enable) } + enabled.storeRelease(enable) + } + + func disable() { + enabled.storeRelease(false) + state.withLock { $0.enabled = false } + } + + func snapshotAndReset() -> CmuxGitRuntimeMetricsSnapshot { + state.withLock { state in + let snapshot = state.snapshot + state = State(enabled: state.enabled) + return snapshot + } + } + + @inline(__always) + func recordRawTrackedStatusScan() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.rawTrackedStatusScanCount &+= 1 + } + } + + @inline(__always) + func recordTrackedStatusCacheHit() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.trackedStatusCacheHitCount &+= 1 + } + } + + @inline(__always) + func recordTrackedStatusInFlightJoin() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.trackedStatusInFlightJoinCount &+= 1 + } + } + + @inline(__always) + func recordTrackedStatusRequest() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.trackedStatusRequestCount &+= 1 + } + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitFallbackRoundID.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitFallbackRoundID.swift new file mode 100644 index 000000000000..7a39ab15fa3d --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitFallbackRoundID.swift @@ -0,0 +1,15 @@ +public import Foundation + +/// Identity of one process-wide five-minute git fallback tick. +public nonisolated struct GitFallbackRoundID: Equatable, Hashable, Sendable { + /// Stable identity of the injected fallback coordinator. + public let namespace: UUID + /// Monotonic tick sequence within ``namespace``. + public let sequence: UInt64 + + /// Creates one explicit process-wide fallback round identity. + public init(namespace: UUID, sequence: UInt64) { + self.namespace = namespace + self.sequence = sequence + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesRepositoryIdentity.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesRepositoryIdentity.swift new file mode 100644 index 000000000000..b0959e4c12e0 --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesRepositoryIdentity.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Stable identity of one worktree and its git directory. +public nonisolated struct GitTrackedChangesRepositoryIdentity: Equatable, Hashable, Sendable { + let workTreeRoot: String + let gitDirectory: String + + init(repository: ResolvedGitRepository) { + self.workTreeRoot = repository.workTreeRoot + self.gitDirectory = repository.gitDirectory + } + + func matches(_ repository: ResolvedGitRepository) -> Bool { + workTreeRoot == repository.workTreeRoot && gitDirectory == repository.gitDirectory + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotAuthority.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotAuthority.swift new file mode 100644 index 000000000000..f526de8ca7b0 --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotAuthority.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Immutable cache authority stamped before a snapshot waits on the process +/// probe limiter. +public nonisolated struct GitTrackedChangesSnapshotAuthority: Equatable, Hashable, Sendable { + let repositoryIdentity: GitTrackedChangesRepositoryIdentity + let repositoryEpoch: UUID + let repositoryRevision: UInt64 + let fallbackRoundID: GitFallbackRoundID? + + init( + repositoryIdentity: GitTrackedChangesRepositoryIdentity, + repositoryEpoch: UUID, + repositoryRevision: UInt64, + fallbackRoundID: GitFallbackRoundID? + ) { + self.repositoryIdentity = repositoryIdentity + self.repositoryEpoch = repositoryEpoch + self.repositoryRevision = repositoryRevision + self.fallbackRoundID = fallbackRoundID + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotRead.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotRead.swift new file mode 100644 index 000000000000..3c2b21d05a9f --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotRead.swift @@ -0,0 +1,4 @@ +struct GitTrackedChangesSnapshotRead: Sendable { + let snapshot: GitTrackedChangesSnapshot + let isCurrent: Bool +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotRequest.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotRequest.swift new file mode 100644 index 000000000000..b40e993d06ca --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedChangesSnapshotRequest.swift @@ -0,0 +1,26 @@ +/// A pre-stamped tracked snapshot request. +/// +/// The optional authority is `nil` only for readers that cannot resolve a real +/// repository, such as isolated state-machine tests. Production watched +/// repositories stamp it before their snapshot waits on the probe limiter. +public nonisolated enum GitTrackedChangesSnapshotRequest: Equatable, Sendable { + /// A filesystem watcher advanced the repository revision. + case watcherEvent( + GitTrackedChangesSnapshotAuthority?, + eventID: GitTrackedPathEventGeneration? = nil + ) + /// One explicit process-wide safety round. + case fallbackRound( + id: GitFallbackRoundID, + authority: GitTrackedChangesSnapshotAuthority? + ) + + var authority: GitTrackedChangesSnapshotAuthority? { + switch self { + case .watcherEvent(let authority, _): + authority + case .fallbackRound(_, let authority): + authority + } + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedPathEventID.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedPathEventID.swift new file mode 100644 index 000000000000..7040ce295363 --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitTrackedPathEventID.swift @@ -0,0 +1,37 @@ +/// Stable FSEvents watermark attached to watcher-triggered Git refreshes. +public nonisolated struct GitTrackedPathEventID: Equatable, Hashable, Comparable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + /// Whether this ID is newer than `previous` in the FSEvents sequence. + /// + /// FSEvents IDs are unsigned serial numbers. RFC 1982 ordering treats a + /// nonzero forward distance below half the sequence space as newer. The + /// same comparison handles normal increments, counter wrap, duplicates, + /// and delayed pre-wrap deliveries without changing modes after a reset. + func isNewer(than previous: Self) -> Bool { + let forwardDistance = rawValue &- previous.rawValue + return forwardDistance != 0 + && forwardDistance < (UInt64.max / 2) + 1 + } +} + +/// Reliability of a watcher event's source identity. +public nonisolated enum GitTrackedPathEventSource: Equatable, Sendable { + /// A normal event with a monotonic FSEvents watermark. + case stable(GitTrackedPathEventID) + /// The event stream dropped data or requested a full rescan. Every delivery + /// advances revision because it cannot be safely deduplicated. + case unknown + /// FSEvents IDs wrapped. Advances revision while preserving the last stable + /// watermark; serial-number ordering accepts new low IDs and rejects delayed + /// deliveries from before the wrap. + case sequenceReset +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitWorkspaceMetadataSnapshot.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitWorkspaceMetadataSnapshot.swift new file mode 100644 index 000000000000..45fe0a372028 --- /dev/null +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Model/GitWorkspaceMetadataSnapshot.swift @@ -0,0 +1,14 @@ +/// Git workspace metadata plus whether its pre-stamped repository authority is +/// still current after the tracked scan completes. +public nonisolated struct GitWorkspaceMetadataSnapshot: Equatable, Sendable { + /// Parsed repository metadata. + public let metadata: GitWorkspaceMetadata + /// `false` when a watcher advanced repository revision during the scan. + public let isCurrent: Bool + + /// Creates an authority-stamped metadata result. + public init(metadata: GitWorkspaceMetadata, isCurrent: Bool) { + self.metadata = metadata + self.isCurrent = isCurrent + } +} diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift index 3ec619aae72e..46bd9dff6561 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift @@ -9,6 +9,7 @@ extension GitMetadataService { /// file status and compares size, mode, and mtime. Gitlink entries are dirty /// when the submodule's checked-out commit differs from the index object ID. nonisolated func gitTrackedChangesSnapshot(repository: ResolvedGitRepository) -> GitTrackedChangesSnapshot { + runtimeMetricsRecorder.recordRawTrackedStatusScan() let indexURL = URL(fileURLWithPath: repository.gitDirectory).appendingPathComponent("index") guard let indexSnapshot = Self.gitIndexSnapshot(indexURL: indexURL) else { return GitTrackedChangesSnapshot( diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/CmuxGitRuntimeMetricsTests.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/CmuxGitRuntimeMetricsTests.swift new file mode 100644 index 000000000000..49ef26af2892 --- /dev/null +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/CmuxGitRuntimeMetricsTests.swift @@ -0,0 +1,95 @@ +import Testing +@testable import CmuxGit + +@Suite(.serialized) +struct CmuxGitRuntimeMetricsTests { + @Test func recordingDefaultsDisabled() { + let metrics = CmuxGitRuntimeMetricsRecorder() + + metrics.recordRawTrackedStatusScan() + metrics.recordTrackedStatusCacheHit() + metrics.recordTrackedStatusInFlightJoin() + metrics.recordTrackedStatusRequest() + + let snapshot = metrics.snapshot() + #expect(snapshot.schemaVersion == 2) + #expect(!snapshot.enabled) + #expect(snapshot.rawTrackedStatusScanCount == 0) + #expect(snapshot.trackedStatusCacheHitCount == 0) + #expect(snapshot.trackedStatusInFlightJoinCount == 0) + #expect(snapshot.trackedStatusRequestCount == 0) + } + + @Test func enabledResetAndAtomicTake() { + let metrics = CmuxGitRuntimeMetricsRecorder() + metrics.reset(enable: true) + + metrics.recordRawTrackedStatusScan() + metrics.recordTrackedStatusCacheHit() + metrics.recordTrackedStatusInFlightJoin() + metrics.recordTrackedStatusRequest() + + let snapshot = metrics.snapshot() + #expect(snapshot.enabled) + #expect(snapshot.rawTrackedStatusScanCount == 1) + #expect(snapshot.trackedStatusCacheHitCount == 1) + #expect(snapshot.trackedStatusInFlightJoinCount == 1) + #expect(snapshot.trackedStatusRequestCount == 1) + #expect(metrics.snapshotAndReset() == snapshot) + #expect(metrics.snapshot() == .zero(enabled: true)) + + metrics.recordRawTrackedStatusScan() + metrics.reset(enable: true) + #expect(metrics.snapshot() == .zero(enabled: true)) + } + + @Test func disablePreservesSnapshotAndStopsRecording() { + let metrics = CmuxGitRuntimeMetricsRecorder() + metrics.reset(enable: true) + metrics.recordRawTrackedStatusScan() + + metrics.disable() + metrics.recordRawTrackedStatusScan() + + let snapshot = metrics.snapshot() + #expect(!snapshot.enabled) + #expect(snapshot.rawTrackedStatusScanCount == 1) + } + + @Test func concurrentRecordingDoesNotLoseUpdates() async { + let metrics = CmuxGitRuntimeMetricsRecorder() + metrics.reset(enable: true) + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<100 { + group.addTask { + for _ in 0..<100 { + metrics.recordRawTrackedStatusScan() + metrics.recordTrackedStatusCacheHit() + metrics.recordTrackedStatusInFlightJoin() + metrics.recordTrackedStatusRequest() + } + } + } + } + + let snapshot = metrics.snapshot() + #expect(snapshot.rawTrackedStatusScanCount == 10_000) + #expect(snapshot.trackedStatusCacheHitCount == 10_000) + #expect(snapshot.trackedStatusInFlightJoinCount == 10_000) + #expect(snapshot.trackedStatusRequestCount == 10_000) + } +} + +private extension CmuxGitRuntimeMetricsSnapshot { + static func zero(enabled: Bool) -> Self { + CmuxGitRuntimeMetricsSnapshot( + schemaVersion: 2, + enabled: enabled, + rawTrackedStatusScanCount: 0, + trackedStatusCacheHitCount: 0, + trackedStatusInFlightJoinCount: 0, + trackedStatusRequestCount: 0 + ) + } +} diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/Fixtures/CountingGitFileStatusReader.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/Fixtures/CountingGitFileStatusReader.swift index 609e550e284b..ff9b960ccfae 100644 --- a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/Fixtures/CountingGitFileStatusReader.swift +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/Fixtures/CountingGitFileStatusReader.swift @@ -37,3 +37,84 @@ final class CountingGitFileStatusReader: GitFileStatusReading, @unchecked Sendab lock.unlock() } } + +/// A counting reader that can hold tracked-file status reads while concurrent +/// metadata requests reach the shared snapshot cache. +final class GatedCountingGitFileStatusReader: GitFileStatusReading, @unchecked Sendable { + private let condition = NSCondition() + private let systemReader = SystemGitFileStatusReader() + private let gatedPath: String + private var callsByPath: [String: Int] = [:] + private var isGateOpen = false + + init(gatedPath: String) { + self.gatedPath = gatedPath + } + + func status(atPath path: String) -> GitFileStatus? { + condition.lock() + callsByPath[path, default: 0] += 1 + condition.broadcast() + while path == gatedPath, !isGateOpen { + condition.wait() + } + condition.unlock() + return systemReader.status(atPath: path) + } + + func callCount(atPath path: String) -> Int { + condition.lock() + defer { condition.unlock() } + return callsByPath[path] ?? 0 + } + + func waitForCallCount( + atPath path: String, + atLeast minimumCount: Int, + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + defer { condition.unlock() } + while callsByPath[path, default: 0] < minimumCount { + guard condition.wait(until: deadline) else { + return callsByPath[path, default: 0] >= minimumCount + } + } + return true + } + + func openGate() { + condition.lock() + isGateOpen = true + condition.broadcast() + condition.unlock() + } +} + +/// Releases all concurrent test operations together after every caller has +/// arrived, making a cache-miss race deterministic. +actor ConcurrentOperationStartGate { + private let expectedCount: Int + private var arrivalCount = 0 + private var waiters: [CheckedContinuation] = [] + + init(expectedCount: Int) { + self.expectedCount = expectedCount + } + + func wait() async { + arrivalCount += 1 + if arrivalCount == expectedCount { + let readyWaiters = waiters + waiters.removeAll() + for waiter in readyWaiters { + waiter.resume() + } + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests+NestedSubmodules.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests+NestedSubmodules.swift new file mode 100644 index 000000000000..3458633cbaf2 --- /dev/null +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests+NestedSubmodules.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CmuxGit + +extension GitMetadataServiceTests { + @Test func watchedPathsRecurseIntoNestedSubmodules() throws { + let parent = try GitRepositoryFixture() + try parent.writeBranch("main") + let commit = String(repeating: "2", count: 40) + + // parent -> vendor/mid -> vendor/mid/deep (gitlinks at two depths) + let midRoot = parent.root.appendingPathComponent("vendor/mid", isDirectory: true) + let midGit = midRoot.appendingPathComponent(".git", isDirectory: true) + let deepRoot = midRoot.appendingPathComponent("deep", isDirectory: true) + let deepGit = deepRoot.appendingPathComponent(".git", isDirectory: true) + for dir in [midGit, deepGit] { + try FileManager.default.createDirectory( + at: dir.appendingPathComponent("refs/heads"), + withIntermediateDirectories: true + ) + try "\(commit)\n".write(to: dir.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) + } + // mid's index records deep as a gitlink; parent's records mid. + try GitIndexFixture(version: 2, entries: [ + GitIndexFixture.Entry(path: "deep", mode: 0o160000, objectID: commit, size: 0), + ]).data().write(to: midGit.appendingPathComponent("index")) + try parent.writeIndex(GitIndexFixture(version: 2, entries: [ + GitIndexFixture.Entry(path: "vendor/mid", mode: 0o160000, objectID: commit, size: 0), + ])) + + let paths = try #require(GitMetadataService.workspaceGitMetadataWatchedPaths(for: parent.root.path)) + #expect(paths.contains(midGit.appendingPathComponent("HEAD").standardizedFileURL.path)) + #expect(paths.contains(deepGit.appendingPathComponent("HEAD").standardizedFileURL.path), + "nested submodule metadata must be watched") + } + + // MARK: Execution contract + + /// Pins the SE-0338 contract the service relies on: a `nonisolated async` + /// method awaited from the main actor runs on the global concurrent executor, + /// not the main thread. If CmuxGit ever adopts `NonisolatedNonsendingByDefault`, + /// this fails — annotate the reads `@concurrent` to restore off-main execution. + @MainActor @Test func nonisolatedAsyncReadsRunOffTheMainThread() async { + #expect(pthread_main_np() != 0) // we start on the main actor's thread + let hopped = await GitMetadataService().executionHopsOffCallersThread() + #expect(hopped, "nonisolated async must hop off the caller's thread (SE-0338)") + } +} diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests.swift index 00861b5ccb24..3cf011f865f3 100644 --- a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests.swift +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests.swift @@ -211,15 +211,19 @@ import Testing let filePath = fixture.root.appendingPathComponent("file.txt").path let reader = CountingGitFileStatusReader() let service = GitMetadataService(fileStatusReader: reader) - let generation = GitTrackedPathEventGeneration(namespace: UUID(), generation: 10) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority = await service.trackedChangesSnapshotAuthority( + for: identity, + fallbackRoundID: nil + ) let first = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation + snapshotRequest: .watcherEvent(authority) ) let second = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation + snapshotRequest: .watcherEvent(authority) ) #expect(first == second) @@ -235,16 +239,21 @@ import Testing let fileURL = fixture.root.appendingPathComponent("file.txt") let reader = CountingGitFileStatusReader() let service = GitMetadataService(fileStatusReader: reader) - let namespace = UUID() + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let initialAuthority = await service.trackedChangesSnapshotAuthority( + for: identity, + fallbackRoundID: nil + ) let clean = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: GitTrackedPathEventGeneration(namespace: namespace, generation: 20) + snapshotRequest: .watcherEvent(initialAuthority) ) try "hello, dirty".write(to: fileURL, atomically: true, encoding: .utf8) + let eventAuthority = await service.recordTrackedPathEvent(for: identity) let dirty = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: GitTrackedPathEventGeneration(namespace: namespace, generation: 21) + snapshotRequest: .watcherEvent(eventAuthority) ) #expect(clean.isDirty == false) @@ -252,7 +261,7 @@ import Testing #expect(reader.callCount(atPath: fileURL.path) == 2) } - @Test func sameGenerationFromIndependentOwnersRescansAndDetectsUnstagedEdit() async throws { + @Test func collidingFallbackGenerationFromIndependentNamespacesRescans() async throws { let fixture = try GitRepositoryFixture() try fixture.writeBranch("main") let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") @@ -261,17 +270,28 @@ import Testing let fileURL = fixture.root.appendingPathComponent("file.txt") let reader = CountingGitFileStatusReader() let service = GitMetadataService(fileStatusReader: reader) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) let firstOwner = UUID() let secondOwner = UUID() + let firstRound = GitFallbackRoundID(namespace: firstOwner, sequence: 1) + let secondRound = GitFallbackRoundID(namespace: secondOwner, sequence: 1) + let firstAuthority = await service.trackedChangesSnapshotAuthority( + for: identity, + fallbackRoundID: firstRound + ) let clean = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: GitTrackedPathEventGeneration(namespace: firstOwner, generation: 1) + snapshotRequest: .fallbackRound(id: firstRound, authority: firstAuthority) ) try "hello, dirty".write(to: fileURL, atomically: true, encoding: .utf8) + let secondAuthority = await service.trackedChangesSnapshotAuthority( + for: identity, + fallbackRoundID: secondRound + ) let dirty = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: GitTrackedPathEventGeneration(namespace: secondOwner, generation: 1) + snapshotRequest: .fallbackRound(id: secondRound, authority: secondAuthority) ) #expect(clean.isDirty == false) @@ -289,11 +309,15 @@ import Testing let filePath = fixture.root.appendingPathComponent("file.txt").path let reader = CountingGitFileStatusReader() let service = GitMetadataService(fileStatusReader: reader) - let generation = GitTrackedPathEventGeneration(namespace: UUID(), generation: 30) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority = await service.trackedChangesSnapshotAuthority( + for: identity, + fallbackRoundID: nil + ) _ = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation + snapshotRequest: .watcherEvent(authority) ) var changedIndexStatus = try #require(reader.statusWithoutRecording(atPath: indexPath)) changedIndexStatus = GitFileStatus( @@ -305,7 +329,7 @@ import Testing reader.overrideStatus(changedIndexStatus, atPath: indexPath) _ = await service.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation + snapshotRequest: .watcherEvent(authority) ) #expect(reader.callCount(atPath: filePath) == 2) @@ -453,46 +477,4 @@ import Testing #expect(GitMetadataService.gitRefValue(repository: repository, refName: "refs/heads/main") != nil) } - @Test func watchedPathsRecurseIntoNestedSubmodules() throws { - let parent = try GitRepositoryFixture() - try parent.writeBranch("main") - let commit = String(repeating: "2", count: 40) - - // parent -> vendor/mid -> vendor/mid/deep (gitlinks at two depths) - let midRoot = parent.root.appendingPathComponent("vendor/mid", isDirectory: true) - let midGit = midRoot.appendingPathComponent(".git", isDirectory: true) - let deepRoot = midRoot.appendingPathComponent("deep", isDirectory: true) - let deepGit = deepRoot.appendingPathComponent(".git", isDirectory: true) - for dir in [midGit, deepGit] { - try FileManager.default.createDirectory( - at: dir.appendingPathComponent("refs/heads"), - withIntermediateDirectories: true - ) - try "\(commit)\n".write(to: dir.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8) - } - // mid's index records deep as a gitlink; parent's records mid. - try GitIndexFixture(version: 2, entries: [ - GitIndexFixture.Entry(path: "deep", mode: 0o160000, objectID: commit, size: 0), - ]).data().write(to: midGit.appendingPathComponent("index")) - try parent.writeIndex(GitIndexFixture(version: 2, entries: [ - GitIndexFixture.Entry(path: "vendor/mid", mode: 0o160000, objectID: commit, size: 0), - ])) - - let paths = try #require(GitMetadataService.workspaceGitMetadataWatchedPaths(for: parent.root.path)) - #expect(paths.contains(midGit.appendingPathComponent("HEAD").standardizedFileURL.path)) - #expect(paths.contains(deepGit.appendingPathComponent("HEAD").standardizedFileURL.path), - "nested submodule metadata must be watched") - } - - // MARK: Execution contract - - /// Pins the SE-0338 contract the service relies on: a `nonisolated async` - /// method awaited from the main actor runs on the global concurrent executor, - /// not the main thread. If CmuxGit ever adopts `NonisolatedNonsendingByDefault`, - /// this fails — annotate the reads `@concurrent` to restore off-main execution. - @MainActor @Test func nonisolatedAsyncReadsRunOffTheMainThread() async { - #expect(pthread_main_np() != 0) // we start on the main actor's thread - let hopped = await GitMetadataService().executionHopsOffCallersThread() - #expect(hopped, "nonisolated async must hop off the caller's thread (SE-0338)") - } } diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitOwnerPerformanceExerciseTests.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitOwnerPerformanceExerciseTests.swift new file mode 100644 index 000000000000..aa0797405702 --- /dev/null +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitOwnerPerformanceExerciseTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import CmuxGit + +@Suite struct GitOwnerPerformanceExerciseTests { + @Test(.timeLimit(.minutes(1))) + func exerciseUsesNormalSingleFlightAndRawScanRecordSites() async throws { + let metrics = CmuxGitRuntimeMetrics() + metrics.reset(enable: true) + + let exercise = GitOwnerPerformanceExercise(runtimeMetricsRecorder: metrics) + let result = try await exercise.run(requestCount: 4) + let snapshot = metrics.snapshot() + + #expect(result.requestCount == 4) + #expect(result.completedSnapshotCount == 4) + #expect(result.allSnapshotsMatched) + #expect(result.allWaitersRegistered) + #expect(snapshot.trackedStatusRequestCount == 4) + #expect(snapshot.rawTrackedStatusScanCount == 1) + #expect(snapshot.trackedStatusCacheHitCount == 0) + #expect(snapshot.trackedStatusInFlightJoinCount == 3) + } +} diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests+SingleFlight.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests+SingleFlight.swift new file mode 100644 index 000000000000..84084e5ba7ef --- /dev/null +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests+SingleFlight.swift @@ -0,0 +1,370 @@ +import Foundation +import Testing +@testable import CmuxGit + +extension GitTrackedChangesSnapshotCacheTests { + @Test(.timeLimit(.minutes(1))) + func concurrentServicesSharingCacheRunOneTrackedScan() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") + try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let filePath = fixture.root.appendingPathComponent("file.txt").path + let reader = GatedCountingGitFileStatusReader(gatedPath: filePath) + let metrics = CmuxGitRuntimeMetrics() + metrics.reset(enable: true) + let scope = GitTrackedChangesSnapshotScope(runtimeMetricsRecorder: metrics) + let callerCount = 8 + let startGate = ConcurrentOperationStartGate(expectedCount: callerCount) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority = await scope.authority(for: identity, fallbackRoundID: nil) + let services = (0..= 1 }) + for _ in 0..<1_000 { + await Task.yield() + } + let trackedScanOperationCount = reader.callCount(atPath: filePath) + reader.openGate() + let snapshots = await snapshotsTask.value + + #expect(trackedScanOperationCount == 1) + #expect(snapshots.count == callerCount) + let runtime = metrics.snapshot() + #expect(runtime.schemaVersion == 2) + #expect(runtime.trackedStatusRequestCount == callerCount) + #expect(runtime.rawTrackedStatusScanCount == 1) + #expect(runtime.trackedStatusCacheHitCount == 0) + #expect(runtime.trackedStatusInFlightJoinCount == callerCount - 1) + #expect( + runtime.rawTrackedStatusScanCount + + runtime.trackedStatusCacheHitCount + + runtime.trackedStatusInFlightJoinCount == runtime.trackedStatusRequestCount + ) + let firstSnapshot = try #require(snapshots.first) + #expect(snapshots.allSatisfy { $0 == firstSnapshot }) + } + + @Test func delayedFallbackRoundReusesItsStampedAuthority() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") + try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let filePath = fixture.root.appendingPathComponent("file.txt").path + let reader = CountingGitFileStatusReader() + let scope = GitTrackedChangesSnapshotScope() + let service = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let namespace = UUID() + let generation40 = GitFallbackRoundID(namespace: namespace, sequence: 40) + let generation41 = GitFallbackRoundID(namespace: namespace, sequence: 41) + let authority40 = await scope.authority(for: identity, fallbackRoundID: generation40) + let authority41 = await scope.authority(for: identity, fallbackRoundID: generation41) + + _ = await service.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .fallbackRound(id: generation40, authority: authority40) + ) + _ = await service.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .fallbackRound(id: generation41, authority: authority41) + ) + _ = await service.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .fallbackRound(id: generation40, authority: authority40) + ) + + #expect(reader.callCount(atPath: filePath) == 2) + } + + @Test(.timeLimit(.minutes(1))) + func delayedFirstRoundSurvivesSixLaterRoundStamps() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") + try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let filePath = fixture.root.appendingPathComponent("file.txt").path + let reader = GatedCountingGitFileStatusReader(gatedPath: filePath) + let scope = GitTrackedChangesSnapshotScope(maximumSnapshotCount: 16) + let service = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let namespace = UUID() + let firstRound = GitFallbackRoundID(namespace: namespace, sequence: 1) + let firstAuthority = await scope.authority(for: identity, fallbackRoundID: firstRound) + let firstTask = Task { + await service.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .fallbackRound(id: firstRound, authority: firstAuthority) + ) + } + defer { + reader.openGate() + firstTask.cancel() + } + + #expect(await waitUntil { reader.callCount(atPath: filePath) == 1 }) + for generation in 2...7 { + let round = GitFallbackRoundID( + namespace: namespace, + sequence: UInt64(generation) + ) + _ = await scope.authority(for: identity, fallbackRoundID: round) + } + reader.openGate() + _ = await firstTask.value + _ = await service.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .fallbackRound(id: firstRound, authority: firstAuthority) + ) + + #expect(reader.callCount(atPath: filePath) == 1) + } + + @Test(.timeLimit(.minutes(1))) + func watcherRevisionDuringFallbackReturnsSupersededWithoutJoining() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") + try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let filePath = fixture.root.appendingPathComponent("file.txt").path + let reader = GatedCountingGitFileStatusReader(gatedPath: filePath) + let scope = GitTrackedChangesSnapshotScope() + let service = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let round = GitFallbackRoundID(namespace: UUID(), sequence: 1) + let fallbackAuthority = await scope.authority(for: identity, fallbackRoundID: round) + let fallbackTask = Task { + await service.gitTrackedChangesSnapshotRead( + repository: repository, + snapshotRequest: .fallbackRound(id: round, authority: fallbackAuthority) + ) + } + defer { + reader.openGate() + fallbackTask.cancel() + } + + #expect(await waitUntil { reader.callCount(atPath: filePath) == 1 }) + let watcherAuthority = await scope.recordWatcherEvent(for: identity) + let watcherTask = Task { + await service.gitTrackedChangesSnapshotRead( + repository: repository, + snapshotRequest: .watcherEvent(watcherAuthority) + ) + } + #expect(await waitUntil { reader.callCount(atPath: filePath) == 2 }) + reader.openGate() + let fallbackRead = try #require(await fallbackTask.value) + let watcherRead = try #require(await watcherTask.value) + + #expect(!fallbackRead.isCurrent) + #expect(watcherRead.isCurrent) + #expect(reader.callCount(atPath: filePath) == 2) + } + + @Test(.timeLimit(.minutes(1))) + func missingIndexStillReportsSupersededAuthority() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let repository = try #require( + GitMetadataService.resolveGitRepository(containing: fixture.root.path) + ) + let indexPath = fixture.gitDirectory.appendingPathComponent("index").path + let reader = GatedCountingGitFileStatusReader(gatedPath: indexPath) + let scope = GitTrackedChangesSnapshotScope() + let service = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority = await scope.authority(for: identity, fallbackRoundID: nil) + let readTask = Task { + await service.gitTrackedChangesSnapshotRead( + repository: repository, + snapshotRequest: .watcherEvent(authority) + ) + } + defer { + reader.openGate() + readTask.cancel() + } + + #expect(await waitUntil { reader.callCount(atPath: indexPath) == 1 }) + _ = await scope.recordWatcherEvent(for: identity) + reader.openGate() + let read = try #require(await readTask.value) + + #expect(!read.isCurrent) + } + + @Test func repositoryAuthorityStateIsBounded() async throws { + let fixtures = try (0..<3).map { _ -> GitRepositoryFixture in + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + return fixture + } + let scope = GitTrackedChangesSnapshotScope(maximumRepositoryCount: 2) + var identities: [GitTrackedChangesRepositoryIdentity] = [] + var originalAuthorities: [GitTrackedChangesSnapshotAuthority] = [] + + for fixture in fixtures { + let repository = try #require( + GitMetadataService.resolveGitRepository(containing: fixture.root.path) + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + identities.append(identity) + originalAuthorities.append( + await scope.authority(for: identity, fallbackRoundID: nil) + ) + } + + let renewedFirst = await scope.authority( + for: identities[0], + fallbackRoundID: nil + ) + let renewedSecond = await scope.authority( + for: identities[1], + fallbackRoundID: nil + ) + + #expect(renewedFirst.repositoryEpoch != originalAuthorities[0].repositoryEpoch) + #expect(renewedSecond.repositoryEpoch != originalAuthorities[1].repositoryEpoch) + } + + @Test func refreshedCacheEntryMovesBehindOlderEntryForEviction() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let cache = GitTrackedChangesSnapshotCache(maximumEntryCount: 2) + let indexStatSignature = GitIndexStatSignature( + size: 1, + mtimeSeconds: 2, + mtimeNanoseconds: 3 + ) + let firstSnapshot = GitTrackedChangesSnapshot( + isDirty: false, + indexSignature: "first", + indexContentSignature: "first-content" + ) + let refreshedFirstSnapshot = GitTrackedChangesSnapshot( + isDirty: true, + indexSignature: "first-refreshed", + indexContentSignature: "first-refreshed-content" + ) + let secondSnapshot = GitTrackedChangesSnapshot( + isDirty: false, + indexSignature: "second", + indexContentSignature: "second-content" + ) + let thirdSnapshot = GitTrackedChangesSnapshot( + isDirty: false, + indexSignature: "third", + indexContentSignature: "third-content" + ) + let namespace = UUID() + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority1 = GitTrackedChangesSnapshotAuthority( + repositoryIdentity: identity, + repositoryEpoch: namespace, + repositoryRevision: 1, + fallbackRoundID: nil + ) + let authority2 = GitTrackedChangesSnapshotAuthority( + repositoryIdentity: identity, + repositoryEpoch: namespace, + repositoryRevision: 2, + fallbackRoundID: nil + ) + let authority3 = GitTrackedChangesSnapshotAuthority( + repositoryIdentity: identity, + repositoryEpoch: namespace, + repositoryRevision: 3, + fallbackRoundID: nil + ) + + await cache.store( + firstSnapshot, + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority1 + ) + await cache.store( + secondSnapshot, + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority2 + ) + await cache.store( + refreshedFirstSnapshot, + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority1 + ) + await cache.store( + thirdSnapshot, + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority3 + ) + + let refreshedFirst = await cache.snapshot( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority1 + ) + let evictedSecond = await cache.snapshot( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority2 + ) + let third = await cache.snapshot( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority3 + ) + + #expect(refreshedFirst == refreshedFirstSnapshot) + #expect(evictedSecond == nil) + #expect(third == thirdSnapshot) + } +} diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests.swift index 34446776ed71..0286b46f7ab5 100644 --- a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests.swift +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitTrackedChangesSnapshotCacheTests.swift @@ -2,114 +2,432 @@ import Foundation import Testing @testable import CmuxGit +private final class BlockingSnapshotLoadGate: @unchecked Sendable { + private let condition = NSCondition() + private var didStart = false + private var isOpen = false + + var hasStarted: Bool { + condition.lock() + defer { condition.unlock() } + return didStart + } + + func waitUntilReleased() { + condition.lock() + didStart = true + condition.broadcast() + while !isOpen { + condition.wait() + } + condition.unlock() + } + + func open() { + condition.lock() + isOpen = true + condition.broadcast() + condition.unlock() + } +} + @Suite struct GitTrackedChangesSnapshotCacheTests { - @Test func alternatingGenerationsKeepSeparateTrackedSnapshotCacheEntries() async throws { + func waitUntil( + maxYields: Int = 5_000, + _ predicate: () -> Bool + ) async -> Bool { + for _ in 0..= 1 }) + for _ in 0..<1_000 { + await Task.yield() + } + let scanCountWhileBlocked = reader.callCount(atPath: filePath) + reader.openGate() + let snapshots = await fallbackTask.value + + #expect(scanCountWhileBlocked == 1) + #expect(snapshots.count == 2) + } + + @Test(.timeLimit(.minutes(1))) + func duplicateWindowWatcherDeliveryRunsOneTrackedScan() async throws { let fixture = try GitRepositoryFixture() try fixture.writeBranch("main") let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) - let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let repository = try #require( + GitMetadataService.resolveGitRepository(containing: fixture.root.path) + ) let filePath = fixture.root.appendingPathComponent("file.txt").path - let reader = CountingGitFileStatusReader() - let service = GitMetadataService(fileStatusReader: reader) - let namespace = UUID() - let generation40 = GitTrackedPathEventGeneration(namespace: namespace, generation: 40) - let generation41 = GitTrackedPathEventGeneration(namespace: namespace, generation: 41) + let reader = GatedCountingGitFileStatusReader(gatedPath: filePath) + let scope = GitTrackedChangesSnapshotScope() + let firstWindow = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let secondWindow = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let sourceEventID = GitTrackedPathEventID(rawValue: UInt64.max - 2) + let firstAuthority = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(sourceEventID) + ) + let secondAuthority = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(sourceEventID) + ) + let startGate = ConcurrentOperationStartGate(expectedCount: 2) + let snapshotsTask = Task { + await withTaskGroup(of: GitTrackedChangesSnapshot.self) { group in + group.addTask { + await startGate.wait() + return await firstWindow.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .watcherEvent(firstAuthority) + ) + } + group.addTask { + await startGate.wait() + return await secondWindow.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .watcherEvent(secondAuthority) + ) + } + return await group.reduce(into: []) { $0.append($1) } + } + } + defer { + reader.openGate() + snapshotsTask.cancel() + } - _ = await service.gitTrackedChangesSnapshot( + #expect(firstAuthority == secondAuthority) + #expect(await waitUntil { reader.callCount(atPath: filePath) == 1 }) + reader.openGate() + #expect(await snapshotsTask.value.count == 2) + #expect(reader.callCount(atPath: filePath) == 1) + + let newerAuthority = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: UInt64.max - 1)) + ) + #expect(newerAuthority != firstAuthority) + _ = await firstWindow.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation40 + snapshotRequest: .watcherEvent(newerAuthority) ) - _ = await service.gitTrackedChangesSnapshot( + #expect(reader.callCount(atPath: filePath) == 2) + + let olderAuthority = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: UInt64.max - 3)) + ) + #expect(olderAuthority == newerAuthority) + _ = await secondWindow.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation41 + snapshotRequest: .watcherEvent(olderAuthority) ) - _ = await service.gitTrackedChangesSnapshot( + #expect(reader.callCount(atPath: filePath) == 2) + + let unknownAuthority = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .unknown + ) + #expect(unknownAuthority != olderAuthority) + _ = await firstWindow.gitTrackedChangesSnapshot( repository: repository, - trackedPathEventGeneration: generation40 + snapshotRequest: .watcherEvent(unknownAuthority) ) + #expect(reader.callCount(atPath: filePath) == 3) - #expect(reader.callCount(atPath: filePath) == 2) + let secondUnknownAuthority = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .unknown + ) + #expect(secondUnknownAuthority != unknownAuthority) + _ = await secondWindow.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .watcherEvent(secondUnknownAuthority) + ) + #expect(reader.callCount(atPath: filePath) == 4) + + let resetAuthority = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .sequenceReset + ) + let postResetAuthority = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: 1)) + ) + let duplicatePostResetAuthority = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: 1)) + ) + #expect(resetAuthority != secondUnknownAuthority) + #expect(postResetAuthority != resetAuthority) + #expect(duplicatePostResetAuthority == postResetAuthority) } - @Test func refreshedCacheEntryMovesBehindOlderEntryForEviction() async throws { + @Test func delayedPreResetWatermarkCannotBlockWrappedSequence() async throws { let fixture = try GitRepositoryFixture() try fixture.writeBranch("main") - let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) - let cache = GitTrackedChangesSnapshotCache(maximumEntryCount: 2) - let indexStatSignature = GitIndexStatSignature( - size: 1, - mtimeSeconds: 2, - mtimeNanoseconds: 3 + let repository = try #require( + GitMetadataService.resolveGitRepository(containing: fixture.root.path) ) - let firstSnapshot = GitTrackedChangesSnapshot( - isDirty: false, - indexSignature: "first", - indexContentSignature: "first-content" + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let scope = GitTrackedChangesSnapshotScope() + let firstWindow = GitMetadataService(trackedChangesSnapshotScope: scope) + let secondWindow = GitMetadataService(trackedChangesSnapshotScope: scope) + + let preReset = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: UInt64.max - 3)) ) - let refreshedFirstSnapshot = GitTrackedChangesSnapshot( - isDirty: true, - indexSignature: "first-refreshed", - indexContentSignature: "first-refreshed-content" - ) - let secondSnapshot = GitTrackedChangesSnapshot( - isDirty: false, - indexSignature: "second", - indexContentSignature: "second-content" - ) - let thirdSnapshot = GitTrackedChangesSnapshot( - isDirty: false, - indexSignature: "third", - indexContentSignature: "third-content" - ) - let namespace = UUID() - let generation1 = GitTrackedPathEventGeneration(namespace: namespace, generation: 1) - let generation2 = GitTrackedPathEventGeneration(namespace: namespace, generation: 2) - let generation3 = GitTrackedPathEventGeneration(namespace: namespace, generation: 3) - - await cache.store( - firstSnapshot, - repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation1 + let reset = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .sequenceReset ) - await cache.store( - secondSnapshot, - repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation2 + let delayedNewerPreReset = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: UInt64.max - 2)) ) - await cache.store( - refreshedFirstSnapshot, - repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation1 + let delayedOlderPreReset = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: UInt64.max - 4)) ) - await cache.store( - thirdSnapshot, - repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation3 + let wrapped = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: 1)) + ) + let repeatedReset = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .sequenceReset + ) + let duplicateWrapped = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: 1)) + ) + let delayedPreResetAfterWrap = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: UInt64.max - 1)) + ) + let nextWrapped = await firstWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: 2)) + ) + let duplicateNextWrapped = await secondWindow.recordTrackedPathEvent( + for: identity, + sourceEvent: .stable(GitTrackedPathEventID(rawValue: 2)) ) - let refreshedFirst = await cache.snapshot( + #expect(reset != preReset) + #expect(delayedNewerPreReset != reset) + #expect(delayedOlderPreReset == delayedNewerPreReset) + #expect(wrapped != delayedNewerPreReset) + #expect(repeatedReset != wrapped) + #expect(duplicateWrapped == repeatedReset) + #expect(delayedPreResetAfterWrap == repeatedReset) + #expect(nextWrapped != repeatedReset) + #expect(duplicateNextWrapped == nextWrapped) + } + + @Test func laterFallbackRescansAndCatchesMissedWatcherEvent() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") + try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let fileURL = fixture.root.appendingPathComponent("file.txt") + let reader = CountingGitFileStatusReader() + let scope = GitTrackedChangesSnapshotScope() + let service = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope + ) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let fallbackNamespace = UUID() + let firstRound = GitFallbackRoundID(namespace: fallbackNamespace, sequence: 1) + let firstAuthority = await scope.authority(for: identity, fallbackRoundID: firstRound) + + let clean = await service.gitTrackedChangesSnapshot( repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation1 + snapshotRequest: .fallbackRound(id: firstRound, authority: firstAuthority) ) - let evictedSecond = await cache.snapshot( + try "hello, dirty".write(to: fileURL, atomically: true, encoding: .utf8) + let secondRound = GitFallbackRoundID(namespace: fallbackNamespace, sequence: 2) + let secondAuthority = await scope.authority(for: identity, fallbackRoundID: secondRound) + let dirty = await service.gitTrackedChangesSnapshot( repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation2 + snapshotRequest: .fallbackRound(id: secondRound, authority: secondAuthority) + ) + + #expect(clean.isDirty == false) + #expect(dirty.isDirty) + #expect(reader.callCount(atPath: fileURL.path) == 2) + } + + @Test(.timeLimit(.minutes(1))) + func allCanceledWaitersCleanUpAfterSharedLoadCompletes() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let entry = try fixture.writeWorkingTreeFile("file.txt", contents: "hello") + try fixture.writeIndex(GitIndexFixture(version: 2, entries: [entry])) + let repository = try #require(GitMetadataService.resolveGitRepository(containing: fixture.root.path)) + let filePath = fixture.root.appendingPathComponent("file.txt").path + let reader = GatedCountingGitFileStatusReader(gatedPath: filePath) + let cache = GitTrackedChangesSnapshotCache() + let scope = GitTrackedChangesSnapshotScope(snapshotCache: cache) + let service = GitMetadataService( + fileStatusReader: reader, + trackedChangesSnapshotScope: scope ) - let third = await cache.snapshot( + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let authority = await scope.authority(for: identity, fallbackRoundID: nil) + let startGate = ConcurrentOperationStartGate(expectedCount: 2) + let canceledWaiters = (0..<2).map { _ in + Task { + await startGate.wait() + return await service.gitTrackedChangesSnapshot( + repository: repository, + snapshotRequest: .watcherEvent(authority) + ) + } + } + defer { reader.openGate() } + + #expect(await waitUntil { reader.callCount(atPath: filePath) == 1 }) + for waiter in canceledWaiters { + waiter.cancel() + } + for waiter in canceledWaiters { + _ = await waiter.value + } + reader.openGate() + _ = await service.gitTrackedChangesSnapshot( repository: repository, - indexStatSignature: indexStatSignature, - trackedPathEventGeneration: generation3 + snapshotRequest: .watcherEvent(authority) ) - #expect(refreshedFirst == refreshedFirstSnapshot) - #expect(evictedSecond == nil) - #expect(third == thirdSnapshot) + #expect(reader.callCount(atPath: filePath) == 2) } + + @Test(.timeLimit(.minutes(1))) + func cancellationBeforeLoadReleaseNeverStoresLateCompletion() async throws { + let fixture = try GitRepositoryFixture() + try fixture.writeBranch("main") + let repository = try #require( + GitMetadataService.resolveGitRepository(containing: fixture.root.path) + ) + let cache = GitTrackedChangesSnapshotCache(maximumEntryCount: 256) + let identity = GitTrackedChangesRepositoryIdentity(repository: repository) + let epoch = UUID() + let indexStatSignature = GitIndexStatSignature( + size: 1, + mtimeSeconds: 2, + mtimeNanoseconds: 3 + ) + let snapshot = GitTrackedChangesSnapshot( + isDirty: true, + indexSignature: "late", + indexContentSignature: "late-content" + ) + + var cancellationViolations = 0 + for revision in 1...20 { + let authority = GitTrackedChangesSnapshotAuthority( + repositoryIdentity: identity, + repositoryEpoch: epoch, + repositoryRevision: UInt64(revision), + fallbackRoundID: nil + ) + let loadGate = BlockingSnapshotLoadGate() + let readTask = Task(priority: .high) { + await cache.snapshot( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority + ) { + loadGate.waitUntilReleased() + return snapshot + } + } + #expect(await waitUntil { loadGate.hasStarted }) + + await Task.detached(priority: .background) { + readTask.cancel() + }.value + loadGate.open() + let canceledResult = await readTask.value + let cachedResult = await cache.snapshot( + repository: repository, + indexStatSignature: indexStatSignature, + authority: authority + ) + if canceledResult != nil || cachedResult != nil { + cancellationViolations += 1 + } + } + #expect(cancellationViolations == 0) + } + } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift index 4ff891e4333c..916f41024181 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift @@ -145,23 +145,5 @@ public struct TerminalCatalogSection: SettingCatalogSection { userDefaultsKey: "terminal.scrollSpeed" ) - /// Whether the per-pane runaway-memory guardrail is active. When on, cmux - /// polls each pane's process-tree memory and warns (badge + dismissible - /// banner with a kill action) when one crosses the threshold, before the OS - /// can OOM-suspend the whole app. On by default. - public let runawayMemoryGuardrailEnabled = DefaultsKey( - id: "terminal.runawayMemoryGuardrail.enabled", - defaultValue: true, - userDefaultsKey: "terminal.runawayMemoryGuardrail.enabled" - ) - - /// Process-tree resident-memory threshold, in gigabytes, at which a pane is - /// flagged as a runaway. Default 8 GB. - public let runawayMemoryGuardrailThresholdGB = DefaultsKey( - id: "terminal.runawayMemoryGuardrail.thresholdGB", - defaultValue: 8, - userDefaultsKey: "terminal.runawayMemoryGuardrail.thresholdGB" - ) - public init() {} } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 22f6b7705d2a..8241fe8e1dd4 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -112,23 +112,6 @@ extension Array where Element == CuratedSettingEntry { .init(section: .terminal, id: "renderer-realization", title: "Reclaim Offscreen Terminal Memory", synonyms: "terminal.rendererRealization.enabled renderer reclaim offscreen memory iosurface gpu idle warm release background terminals"), .init(section: .terminal, id: "renderer-realization-idle", title: "Reclaim After Idle Seconds", synonyms: "terminal.rendererRealization.idleSeconds renderer reclaim idle seconds timeout delay offscreen memory"), .init(section: .terminal, id: "renderer-realization-max", title: "Max Warm Renderers", synonyms: "terminal.rendererRealization.maxWarmRenderers max warm renderers limit count reclaim offscreen gpu"), - .init( - section: .terminal, - id: "memory-guardrail", - title: String(localized: "settings.terminal.memoryGuardrail", defaultValue: "Runaway Memory Guardrail"), - detailText: [ - String(localized: "settings.terminal.memoryGuardrail.subtitleOn", defaultValue: "cmux warns you with a badge and a banner when one pane's process tree uses too much memory, so a single leak can't crash the whole app."), - String(localized: "settings.terminal.memoryGuardrail.subtitleOff", defaultValue: "No warning is shown when a pane's process tree grows large. A leaking process can OOM-suspend the entire app."), - ].joined(separator: " "), - synonyms: "terminal.runawayMemoryGuardrail.enabled runaway memory guardrail high memory warning badge banner oom leak process tree pane" - ), - .init( - section: .terminal, - id: "memory-guardrail-threshold", - title: String(localized: "settings.terminal.memoryGuardrail.threshold", defaultValue: "Memory Warning Threshold (GB)"), - detailText: String(localized: "settings.terminal.memoryGuardrail.threshold.subtitle", defaultValue: "A pane is flagged once its combined process-tree memory crosses this many gigabytes."), - synonyms: "terminal.runawayMemoryGuardrail.thresholdGB memory warning threshold gb gigabytes limit process tree pane" - ), .init(section: .terminal, id: "resume-commands", title: "Resume Commands", synonyms: "terminal.resumeCommands surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), // TextBox diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index 76065c12fbb6..73d22e4c2c33 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -26,8 +26,6 @@ public struct TerminalSection: View { @State private var rendererReclaim: DefaultsValueModel @State private var rendererIdleSeconds: DefaultsValueModel @State private var rendererMaxWarm: DefaultsValueModel - @State private var memGuardrailEnabled: DefaultsValueModel - @State private var memGuardrailThresholdGB: DefaultsValueModel public init( defaultsStore: UserDefaultsSettingsStore, @@ -49,8 +47,6 @@ public struct TerminalSection: View { _rendererReclaim = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rendererRealizationEnabled)) _rendererIdleSeconds = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rendererRealizationIdleSeconds)) _rendererMaxWarm = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.rendererRealizationMaxWarmRenderers)) - _memGuardrailEnabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.runawayMemoryGuardrailEnabled)) - _memGuardrailThresholdGB = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.runawayMemoryGuardrailThresholdGB)) } public var body: some View { @@ -74,8 +70,6 @@ public struct TerminalSection: View { rendererReclaim, rendererIdleSeconds, rendererMaxWarm, - memGuardrailEnabled, - memGuardrailThresholdGB, ] models.forEach { $0.startObserving() } } @@ -327,37 +321,6 @@ public struct TerminalSection: View { ) .accessibilityIdentifier("SettingsTerminalRendererRealizationMaxWarmStepper") } - SettingsCardDivider() - SettingsCardRow( - configurationReview: .settingsOnly, - searchAnchorID: "setting:terminal:memory-guardrail", - String(localized: "settings.terminal.memoryGuardrail", defaultValue: "Runaway Memory Guardrail"), - subtitle: memGuardrailEnabled.current - ? String(localized: "settings.terminal.memoryGuardrail.subtitleOn", defaultValue: "cmux warns you with a badge and a banner when one pane's process tree uses too much memory, so a single leak can't crash the whole app.") - : String(localized: "settings.terminal.memoryGuardrail.subtitleOff", defaultValue: "No warning is shown when a pane's process tree grows large. A leaking process can OOM-suspend the entire app.") - ) { - Toggle("", isOn: Binding(get: { memGuardrailEnabled.current }, set: { memGuardrailEnabled.set($0) })) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsTerminalMemoryGuardrailToggle") - } - SettingsCardDivider() - SettingsCardRow( - configurationReview: .settingsOnly, - searchAnchorID: "setting:terminal:memory-guardrail-threshold", - String(localized: "settings.terminal.memoryGuardrail.threshold", defaultValue: "Memory Warning Threshold (GB)"), - subtitle: String(localized: "settings.terminal.memoryGuardrail.threshold.subtitle", defaultValue: "A pane is flagged once its combined process-tree memory crosses this many gigabytes."), - controlWidth: 120 - ) { - Stepper( - "\(Int(memGuardrailThresholdGB.current))", - value: Binding(get: { memGuardrailThresholdGB.current }, set: { memGuardrailThresholdGB.set($0) }), - in: 1...256, - step: 1 - ) - .disabled(!memGuardrailEnabled.current) - .accessibilityIdentifier("SettingsTerminalMemoryGuardrailThresholdStepper") - } } } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 2fea18222458..9c3932800c6f 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -167,8 +167,6 @@ struct SettingsRowAnchorResolutionTests { "setting:keyboardShortcuts:shortcuts", "setting:keyboardShortcuts:shortcut-chords", "setting:keyboardShortcuts:reset-defaults", - "setting:terminal:memory-guardrail", - "setting:terminal:memory-guardrail-threshold", "setting:settingsJSON:open-file", "setting:settingsJSON:documentation", "setting:reset:reset-all", diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceExecutor.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceExecutor.swift new file mode 100644 index 000000000000..ff56e6c5c53a --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceExecutor.swift @@ -0,0 +1,163 @@ +import Foundation +import CmuxGit + +/// Stages the existing production executor boundary without reaching GitHub. +/// The scheduler, request joining, completion authority, and result projection +/// remain the same paths used by the live executor. +actor SidebarGitOwnerPerformanceExecutor: PullRequestRefreshExecuting { + private struct FetchWaiter { + let minimumCount: Int + let continuation: CheckedContinuation + } + + private var fetchCount = 0 + private let suppressesFetchCountSignal: Bool + private var fetchGateOrder: [UUID] = [] + private var fetchGatesByID: [UUID: CheckedContinuation] = [:] + private var fetchWaitersByID: [UUID: FetchWaiter] = [:] + + init(suppressesFetchCountSignal: Bool = false) { + self.suppressesFetchCountSignal = suppressesFetchCountSignal + } + + func resolveCandidateSeeds( + _ seeds: [WorkspacePullRequestCandidateSeed] + ) -> WorkspacePullRequestCandidateResolution { + let candidates = seeds.map { + WorkspacePullRequestCandidate( + workspaceId: $0.workspaceId, + panelId: $0.panelId, + branch: $0.branch, + repoSlugs: ["cmux/owner-proof"] + ) + } + return WorkspacePullRequestCandidateResolution( + candidates: candidates, + candidateBranchesByRepo: ["cmux/owner-proof": Set(candidates.map(\.branch))], + repoDirectoriesBySlug: ["cmux/owner-proof": "/isolated/owner-proof"] + ) + } + + func fetchRepoResults( + candidateResolution: WorkspacePullRequestCandidateResolution, + cacheBySlug: [String: WorkspacePullRequestRepoCacheEntry], + now: Date, + allowCachedResults: Bool + ) async -> [String: WorkspacePullRequestRepoFetchResult] { + if !suppressesFetchCountSignal { + fetchCount += 1 + resumeSatisfiedFetchWaiters() + } + let didRelease = await waitForFetchRelease() + guard didRelease, !Task.isCancelled else { return [:] } + + var items: [String: GitHubPullRequestProbeItem] = [:] + for branch in candidateResolution.candidateBranchesByRepo["cmux/owner-proof"] ?? [] { + items[branch] = GitHubPullRequestProbeItem( + number: 1, + state: "OPEN", + url: "https://github.invalid/cmux/owner-proof/pull/1", + updatedAt: "2026-07-12T00:00:00Z", + mergedAt: nil, + headRefName: branch, + baseRefName: "main" + ) + } + let cacheEntry = WorkspacePullRequestRepoCacheEntry( + fetchedAt: now, + pullRequestsByBranch: items + ) + return [ + "cmux/owner-proof": .success( + cacheEntry, + usedCache: false, + transientBranches: [] + ), + ] + } + + func waitForFetchCount(_ minimumCount: Int) async throws { + guard fetchCount < minimumCount else { return } + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + fetchWaitersByID[waiterID] = FetchWaiter( + minimumCount: minimumCount, + continuation: continuation + ) + } + try Task.checkCancellation() + } onCancel: { + Task { await self.cancelFetchWaiter(waiterID) } + } + } + + func releaseNextFetch() { + while let gateID = fetchGateOrder.first { + fetchGateOrder.removeFirst() + guard let continuation = fetchGatesByID.removeValue(forKey: gateID) else { + continue + } + continuation.resume(returning: true) + return + } + } + + func cancelAllPending() { + let gates = Array(fetchGatesByID.values) + let waiters = Array(fetchWaitersByID.values) + fetchGatesByID.removeAll() + fetchGateOrder.removeAll() + fetchWaitersByID.removeAll() + for gate in gates { gate.resume(returning: false) } + for waiter in waiters { + waiter.continuation.resume(throwing: CancellationError()) + } + } + + var pendingFetchGateCount: Int { fetchGatesByID.count } + var pendingFetchWaiterCount: Int { fetchWaitersByID.count } + + private func waitForFetchRelease() async -> Bool { + let gateID = UUID() + let didRelease = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + fetchGateOrder.append(gateID) + fetchGatesByID[gateID] = continuation + } + } onCancel: { + Task { await self.cancelFetchGate(gateID) } + } + return didRelease && !Task.isCancelled + } + + private func cancelFetchGate(_ gateID: UUID) { + guard let continuation = fetchGatesByID.removeValue(forKey: gateID) else { return } + fetchGateOrder.removeAll { $0 == gateID } + continuation.resume(returning: false) + } + + private func cancelFetchWaiter(_ waiterID: UUID) { + fetchWaitersByID.removeValue(forKey: waiterID)? + .continuation.resume(throwing: CancellationError()) + } + + private func resumeSatisfiedFetchWaiters() { + let readyIDs = fetchWaitersByID.compactMap { id, waiter in + waiter.minimumCount <= fetchCount ? id : nil + } + for waiterID in readyIDs { + fetchWaitersByID.removeValue(forKey: waiterID)? + .continuation.resume(returning: ()) + } + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceExercise.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceExercise.swift new file mode 100644 index 000000000000..930482261170 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceExercise.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Behavioral result from the isolated pull-request ownership exercise. +/// Counter values remain in ``CmuxSidebarGitRuntimeMetricsSnapshot`` so callers +/// must cross-check behavior against the normal production record sites. +public struct SidebarGitOwnerPerformanceExerciseResult: Sendable { + public let requestCount: Int + public let singleFlightApplyCount: Int + public let staleApplyCountBeforeFollowUp: Int + public let staleFinalApplyCount: Int + public let staleFinalBranch: String? +} + +struct SidebarGitOwnerPerformanceExerciseFault: OptionSet, Sendable { + let rawValue: Int + + static let missingFetchSignal = Self(rawValue: 1 << 0) + static let missingBadgeApplySignal = Self(rawValue: 1 << 1) +} + +struct SidebarGitOwnerPerformanceCleanupSnapshot: Equatable, Sendable { + let pendingFetchGateCount: Int + let pendingFetchWaiterCount: Int + let pendingBadgeWaiterCount: Int + + var isEmpty: Bool { + pendingFetchGateCount == 0 && + pendingFetchWaiterCount == 0 && + pendingBadgeWaiterCount == 0 + } +} + +actor SidebarGitOwnerPerformanceCleanupProbe { + private(set) var snapshots: [SidebarGitOwnerPerformanceCleanupSnapshot] = [] + + func record(_ snapshot: SidebarGitOwnerPerformanceCleanupSnapshot) { + snapshots.append(snapshot) + } +} + +/// Runs the production PR scheduler and completion-authority state machine with +/// an isolated in-memory host and staged executor. It uses no GitHub network or +/// user workspace state. +public struct SidebarGitOwnerPerformanceExercise: Sendable { + private let runtimeMetricsRecorder: CmuxSidebarGitRuntimeMetrics + private let deadlineClock: any GitPollClock + private let lifecycleDeadline: Duration + private let fault: SidebarGitOwnerPerformanceExerciseFault + private let cleanupProbe: SidebarGitOwnerPerformanceCleanupProbe? + + /// Creates an exercise with the production metrics owner and system clock. + @MainActor + public init() { + runtimeMetricsRecorder = SidebarGitMetadataService.runtimeMetrics + deadlineClock = SystemGitPollClock() + lifecycleDeadline = .seconds(10) + fault = [] + cleanupProbe = nil + } + + @MainActor + init( + runtimeMetricsRecorder: CmuxSidebarGitRuntimeMetrics, + deadlineClock: any GitPollClock = SystemGitPollClock(), + lifecycleDeadline: Duration = .seconds(10), + fault: SidebarGitOwnerPerformanceExerciseFault = [], + cleanupProbe: SidebarGitOwnerPerformanceCleanupProbe? = nil + ) { + self.runtimeMetricsRecorder = runtimeMetricsRecorder + self.deadlineClock = deadlineClock + self.lifecycleDeadline = lifecycleDeadline + self.fault = fault + self.cleanupProbe = cleanupProbe + } + + @MainActor + public func run( + requestCount: Int + ) async throws -> SidebarGitOwnerPerformanceExerciseResult { + guard (2...8).contains(requestCount) else { + throw SidebarGitOwnerPerformanceExerciseError.invalidRequestCount + } + try Task.checkCancellation() + + let singleFlightHost = SidebarGitOwnerPerformanceHost( + branch: "feature/single-flight", + suppressesBadgeApplySignal: fault.contains(.missingBadgeApplySignal) + ) + let singleFlightExecutor = SidebarGitOwnerPerformanceExecutor( + suppressesFetchCountSignal: fault.contains(.missingFetchSignal) + ) + let singleFlightService = makeService( + host: singleFlightHost, + executor: singleFlightExecutor + ) + let singleFlightApplyCount: Int + do { + singleFlightService.refreshTrackedWorkspacePullRequestsIfNeeded(reason: "timer") + try await waitForLifecycleSignal( + clock: deadlineClock, + deadline: lifecycleDeadline + ) { + try await singleFlightExecutor.waitForFetchCount(1) + } + for _ in 1.. PullRequestPollService { + let service = PullRequestPollService( + refreshExecutor: executor, + clock: SystemGitPollClock() + ) + service.runtimeMetricsRecorder = runtimeMetricsRecorder + service.attach(host: host) + return service + } + + private func waitForLifecycleSignal( + clock: any GitPollClock, + deadline: Duration, + operation: @escaping @Sendable () async throws -> Void + ) async throws { + try Task.checkCancellation() + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await clock.sleep(for: deadline) + try Task.checkCancellation() + throw SidebarGitOwnerPerformanceExerciseError.lifecycleDeadlineExceeded + } + defer { group.cancelAll() } + guard try await group.next() != nil else { + throw SidebarGitOwnerPerformanceExerciseError.lifecycleDeadlineExceeded + } + } + try Task.checkCancellation() + } + + @MainActor + private func cleanup( + service: PullRequestPollService, + host: SidebarGitOwnerPerformanceHost, + executor: SidebarGitOwnerPerformanceExecutor, + probe: SidebarGitOwnerPerformanceCleanupProbe? + ) async throws { + service.resetWorkspacePullRequestRefreshState() + await executor.cancelAllPending() + host.cancelAllPendingBadgeWaits() + await Task.yield() + let snapshot = SidebarGitOwnerPerformanceCleanupSnapshot( + pendingFetchGateCount: await executor.pendingFetchGateCount, + pendingFetchWaiterCount: await executor.pendingFetchWaiterCount, + pendingBadgeWaiterCount: host.pendingBadgeWaiterCount + ) + await probe?.record(snapshot) + guard snapshot.isEmpty else { + throw SidebarGitOwnerPerformanceExerciseError.cleanupFailed + } + } +} + +enum SidebarGitOwnerPerformanceExerciseError: Error { + case invalidRequestCount + case lifecycleDeadlineExceeded + case cleanupFailed +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceHost.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceHost.swift new file mode 100644 index 000000000000..70c0bcd178f2 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Diagnostics/SidebarGitOwnerPerformanceHost.swift @@ -0,0 +1,143 @@ +import Foundation + +/// Minimal in-memory host for the Release-safe owner exercise. +@MainActor +final class SidebarGitOwnerPerformanceHost: SidebarGitHosting { + private struct BadgeWaiter { + let minimumCount: Int + let continuation: CheckedContinuation + } + + let workspaceId = UUID() + let panelId = UUID() + let directory = "/isolated/owner-proof" + var branch: String + private(set) var badge: SidebarPullRequestBadge? + private(set) var badgeApplyCount = 0 + private let suppressesBadgeApplySignal: Bool + private var badgeWaitersByID: [UUID: BadgeWaiter] = [:] + + init(branch: String, suppressesBadgeApplySignal: Bool = false) { + self.branch = branch + self.suppressesBadgeApplySignal = suppressesBadgeApplySignal + } + + func waitForBadgeApplyCount(_ minimumCount: Int) async throws { + guard badgeApplyCount < minimumCount else { return } + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + badgeWaitersByID[waiterID] = BadgeWaiter( + minimumCount: minimumCount, + continuation: continuation + ) + } + try Task.checkCancellation() + } onCancel: { + Task { @MainActor [weak self] in + self?.cancelBadgeWaiter(waiterID) + } + } + } + + func cancelAllPendingBadgeWaits() { + let waiters = Array(badgeWaitersByID.values) + badgeWaitersByID.removeAll() + for waiter in waiters { + waiter.continuation.resume(throwing: CancellationError()) + } + } + + var pendingBadgeWaiterCount: Int { badgeWaitersByID.count } + + func orderedWorkspaceIds() -> [UUID] { [workspaceId] } + func workspaceExists(_ workspaceId: UUID) -> Bool { workspaceId == self.workspaceId } + func isRemoteWorkspace(_ workspaceId: UUID) -> Bool? { + workspaceExists(workspaceId) ? false : nil + } + func panelIds(in workspaceId: UUID) -> [UUID] { + workspaceExists(workspaceId) ? [panelId] : [] + } + func panelExists(workspaceId: UUID, panelId: UUID) -> Bool { + workspaceId == self.workspaceId && panelId == self.panelId + } + func hasTerminalPanel(workspaceId: UUID, panelId: UUID) -> Bool { + panelExists(workspaceId: workspaceId, panelId: panelId) + } + func isRemoteTerminalPanel(workspaceId: UUID, panelId: UUID) -> Bool { false } + func gitProbeDirectory(workspaceId: UUID, panelId: UUID) -> String? { + panelExists(workspaceId: workspaceId, panelId: panelId) ? directory : nil + } + func hasTrustedRemotePanelDirectory(workspaceId: UUID, panelId: UUID) -> Bool { false } + func panelGitBranch(workspaceId: UUID, panelId: UUID) -> SidebarPanelGitBranch? { + guard panelExists(workspaceId: workspaceId, panelId: panelId) else { return nil } + return SidebarPanelGitBranch(branch: branch, isDirty: false) + } + func panelGitBranchPanelIds(in workspaceId: UUID) -> Set { + workspaceExists(workspaceId) ? [panelId] : [] + } + func panelPullRequestBadge(workspaceId: UUID, panelId: UUID) -> SidebarPullRequestBadge? { + panelExists(workspaceId: workspaceId, panelId: panelId) ? badge : nil + } + func panelPullRequestPanelIds(in workspaceId: UUID) -> Set { + workspaceExists(workspaceId) && badge != nil ? [panelId] : [] + } + func focusedPanelId(in workspaceId: UUID) -> UUID? { + workspaceExists(workspaceId) ? panelId : nil + } + func hasWorkspaceLevelGitSignal(_ workspaceId: UUID) -> Bool { + workspaceExists(workspaceId) + } + func isSelectedFocusedPanel(workspaceId: UUID, panelId: UUID) -> Bool { false } + + func updatePanelDirectory( + workspaceId: UUID, + panelId: UUID, + directory: String, + displayLabel: String? + ) -> Bool { false } + func updateRemotePanelDirectory( + workspaceId: UUID, + panelId: UUID, + directory: String, + displayLabel: String? + ) -> Bool { false } + func updatePanelGitBranch(workspaceId: UUID, panelId: UUID, branch: String, isDirty: Bool) {} + func clearPanelGitBranch(workspaceId: UUID, panelId: UUID) {} + func updatePanelPullRequest( + workspaceId: UUID, + panelId: UUID, + badge: SidebarPullRequestBadge + ) { + guard panelExists(workspaceId: workspaceId, panelId: panelId) else { return } + self.badge = badge + guard !suppressesBadgeApplySignal else { return } + badgeApplyCount += 1 + let readyIDs = badgeWaitersByID.compactMap { id, waiter in + waiter.minimumCount <= badgeApplyCount ? id : nil + } + for waiterID in readyIDs { + badgeWaitersByID.removeValue(forKey: waiterID)? + .continuation.resume(returning: ()) + } + } + func clearPanelPullRequest(workspaceId: UUID, panelId: UUID) { badge = nil } + func schedulePanelGitMetadataProbe(workspaceId: UUID, panelId: UUID, reason: String) {} + func clearAllSidebarGitMetadata() { badge = nil } + func clearAllSidebarPullRequestMetadata() { badge = nil } + + var isGitMetadataWatchEnabled: Bool { true } + var isPullRequestPollingEnabled: Bool { true } + func mobileHostHasRecentActivity(within interval: TimeInterval) -> Bool { false } + func mobileHostQuietDelay(for interval: TimeInterval) -> TimeInterval { 0 } + + private func cancelBadgeWaiter(_ waiterID: UUID) { + badgeWaitersByID.removeValue(forKey: waiterID)? + .continuation.resume(throwing: CancellationError()) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Hosting/WorkspaceGitMetadataReading.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Hosting/WorkspaceGitMetadataReading.swift index f025cb5dd3bb..141e42d7e9f3 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Hosting/WorkspaceGitMetadataReading.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Hosting/WorkspaceGitMetadataReading.swift @@ -8,12 +8,17 @@ public protocol WorkspaceGitMetadataReading: Sendable { /// Returns the git metadata for `directory`. func workspaceMetadata(for directory: String) async -> GitWorkspaceMetadata - /// Returns the git metadata for `directory`, reusing tracked-change work - /// only when the supplied watcher generation has not changed. + /// Returns git metadata using explicit process-coordinated refresh authority. func workspaceMetadata( for directory: String, - trackedPathEventGeneration: GitTrackedPathEventGeneration? + snapshotRequest: GitTrackedChangesSnapshotRequest? ) async -> GitWorkspaceMetadata + + /// Returns metadata plus whether the stamped repository revision remained current. + func workspaceMetadataSnapshot( + for directory: String, + snapshotRequest: GitTrackedChangesSnapshotRequest? + ) async -> GitWorkspaceMetadataSnapshot } /// Default cache-generation behavior for readers that only implement a full @@ -25,10 +30,23 @@ public extension WorkspaceGitMetadataReading { /// rely on this default to conservatively bypass tracked-change cache reuse. func workspaceMetadata( for directory: String, - trackedPathEventGeneration: GitTrackedPathEventGeneration? + snapshotRequest: GitTrackedChangesSnapshotRequest? ) async -> GitWorkspaceMetadata { await workspaceMetadata(for: directory) } + + func workspaceMetadataSnapshot( + for directory: String, + snapshotRequest: GitTrackedChangesSnapshotRequest? + ) async -> GitWorkspaceMetadataSnapshot { + GitWorkspaceMetadataSnapshot( + metadata: await workspaceMetadata( + for: directory, + snapshotRequest: snapshotRequest + ), + isCurrent: true + ) + } } extension GitMetadataService: WorkspaceGitMetadataReading {} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Metrics/CmuxSidebarGitRuntimeMetrics.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Metrics/CmuxSidebarGitRuntimeMetrics.swift new file mode 100644 index 000000000000..bb80cf997266 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Metrics/CmuxSidebarGitRuntimeMetrics.swift @@ -0,0 +1,370 @@ +internal import CmuxFoundation +import os + +/// Process-wide counters for sidebar Git snapshot and pull-request work. +/// +/// The snapshot contains aggregate counts only. It never records directories, +/// branches, workspace identifiers, pull-request data, or diagnostic reasons, +/// so it is safe to collect in Release builds. Collection defaults off; a +/// disabled record site performs one relaxed atomic load and does not take the +/// counter lock. Enabled updates hold an unfair lock only long enough to +/// increment a fixed-width integer. +public struct CmuxSidebarGitRuntimeMetricsSnapshot: Codable, Equatable, Sendable { + public let schemaVersion: UInt64 + public let enabled: Bool + public let snapshotBatchApplyCount: UInt64 + public let materialChangeCount: UInt64 + public let pullRequestSeedCount: UInt64 + public let pullRequestTraversalCount: UInt64 + public let staleApplyCount: UInt64 + public let pullRequestRefreshRequestCount: UInt64 + public let pullRequestTaskStartedCount: UInt64 + public let pullRequestTaskJoinedCount: UInt64 + public let pullRequestRepoFetchCount: UInt64 + public let pullRequestStaleCompletionRejectedOffMainCount: UInt64 + public let pullRequestMainActorApplyEnteredCount: UInt64 + public let pullRequestFollowUpStartedCount: UInt64 + public let gitStaleCompletionRejectedOffMainCount: UInt64 + public let gitMainActorApplyEnteredCount: UInt64 + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case enabled + case snapshotBatchApplyCount + case materialChangeCount + case pullRequestSeedCount + case pullRequestTraversalCount + case staleApplyCount + case pullRequestRefreshRequestCount + case pullRequestTaskStartedCount + case pullRequestTaskJoinedCount + case pullRequestRepoFetchCount + case pullRequestStaleCompletionRejectedOffMainCount + case pullRequestMainActorApplyEnteredCount + case pullRequestFollowUpStartedCount + case gitStaleCompletionRejectedOffMainCount + case gitMainActorApplyEnteredCount + } +} + +/// Instantiated, thread-safe recorder for +/// ``CmuxSidebarGitRuntimeMetricsSnapshot``. ``SidebarGitMetadataService`` owns +/// the process-wide production instance; tests may inject an independent one. +public final class CmuxSidebarGitRuntimeMetrics: Sendable { + private let recorder: CmuxSidebarGitRuntimeMetricsRecorder + + public init() { + recorder = CmuxSidebarGitRuntimeMetricsRecorder() + } + + /// Returns the current counters without changing them. + public func snapshot() -> CmuxSidebarGitRuntimeMetricsSnapshot { + recorder.snapshot() + } + + /// Clears all counters and selects whether subsequent events are recorded. + public func reset(enable: Bool) { + recorder.reset(enable: enable) + } + + /// Stops recording while preserving the counters already collected. + public func disable() { + recorder.disable() + } + + /// Returns the current counters and clears them in the same critical section. + public func snapshotAndReset() -> CmuxSidebarGitRuntimeMetricsSnapshot { + recorder.snapshotAndReset() + } + + @inline(__always) + func recordSnapshotBatchApply() { + recorder.recordSnapshotBatchApply() + } + + @inline(__always) + func recordMaterialChange() { + recorder.recordMaterialChange() + } + + @inline(__always) + func recordPullRequestSeed() { + recorder.recordPullRequestSeed() + } + + @inline(__always) + func recordPullRequestTraversal() { + recorder.recordPullRequestTraversal() + } + + @inline(__always) + func recordStaleApply() { + recorder.recordStaleApply() + } + + @inline(__always) + func recordPullRequestRefreshRequest() { + recorder.recordPullRequestRefreshRequest() + } + + @inline(__always) + func recordPullRequestTaskStarted() { + recorder.recordPullRequestTaskStarted() + } + + @inline(__always) + func recordPullRequestTaskJoined() { + recorder.recordPullRequestTaskJoined() + } + + @inline(__always) + func recordPullRequestRepoFetch() { + recorder.recordPullRequestRepoFetch() + } + + @inline(__always) + func recordPullRequestStaleCompletionRejectedOffMain() { + recorder.recordPullRequestStaleCompletionRejectedOffMain() + } + + @inline(__always) + func recordPullRequestMainActorApplyEntered() { + recorder.recordPullRequestMainActorApplyEntered() + } + + @inline(__always) + func recordPullRequestFollowUpStarted() { + recorder.recordPullRequestFollowUpStarted() + } + + @inline(__always) + func recordGitStaleCompletionRejectedOffMain() { + recorder.recordGitStaleCompletionRejectedOffMain() + } + + @inline(__always) + func recordGitMainActorApplyEntered() { + recorder.recordGitMainActorApplyEntered() + } +} + +public extension SidebarGitMetadataService { + static func runtimeMetricsSnapshot() -> CmuxSidebarGitRuntimeMetricsSnapshot { + runtimeMetrics.snapshot() + } + + static func resetRuntimeMetrics(enable: Bool) { + runtimeMetrics.reset(enable: enable) + } + + static func disableRuntimeMetrics() { + runtimeMetrics.disable() + } +} + +extension SidebarGitMetadataService { + static func recordSnapshotBatchApply() { + runtimeMetrics.recordSnapshotBatchApply() + } + + static func recordMaterialChange() { + runtimeMetrics.recordMaterialChange() + } + + static func recordStaleApply() { + runtimeMetrics.recordStaleApply() + } +} + +/// An instance-scoped recorder keeps tests independent from live package work. +final class CmuxSidebarGitRuntimeMetricsRecorder: Sendable { + private struct State { + var enabled = false + var snapshotBatchApplyCount: UInt64 = 0 + var materialChangeCount: UInt64 = 0 + var pullRequestSeedCount: UInt64 = 0 + var pullRequestTraversalCount: UInt64 = 0 + var staleApplyCount: UInt64 = 0 + var pullRequestRefreshRequestCount: UInt64 = 0 + var pullRequestTaskStartedCount: UInt64 = 0 + var pullRequestTaskJoinedCount: UInt64 = 0 + var pullRequestRepoFetchCount: UInt64 = 0 + var pullRequestStaleCompletionRejectedOffMainCount: UInt64 = 0 + var pullRequestMainActorApplyEnteredCount: UInt64 = 0 + var pullRequestFollowUpStartedCount: UInt64 = 0 + var gitStaleCompletionRejectedOffMainCount: UInt64 = 0 + var gitMainActorApplyEnteredCount: UInt64 = 0 + + var snapshot: CmuxSidebarGitRuntimeMetricsSnapshot { + CmuxSidebarGitRuntimeMetricsSnapshot( + schemaVersion: 2, + enabled: enabled, + snapshotBatchApplyCount: snapshotBatchApplyCount, + materialChangeCount: materialChangeCount, + pullRequestSeedCount: pullRequestSeedCount, + pullRequestTraversalCount: pullRequestTraversalCount, + staleApplyCount: staleApplyCount, + pullRequestRefreshRequestCount: pullRequestRefreshRequestCount, + pullRequestTaskStartedCount: pullRequestTaskStartedCount, + pullRequestTaskJoinedCount: pullRequestTaskJoinedCount, + pullRequestRepoFetchCount: pullRequestRepoFetchCount, + pullRequestStaleCompletionRejectedOffMainCount: pullRequestStaleCompletionRejectedOffMainCount, + pullRequestMainActorApplyEnteredCount: pullRequestMainActorApplyEnteredCount, + pullRequestFollowUpStartedCount: pullRequestFollowUpStartedCount, + gitStaleCompletionRejectedOffMainCount: gitStaleCompletionRejectedOffMainCount, + gitMainActorApplyEnteredCount: gitMainActorApplyEnteredCount + ) + } + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + private let enabled = AtomicBooleanGate(false) + + func snapshot() -> CmuxSidebarGitRuntimeMetricsSnapshot { + state.withLock { $0.snapshot } + } + + func reset(enable: Bool) { + state.withLock { $0 = State(enabled: enable) } + enabled.storeRelease(enable) + } + + func disable() { + enabled.storeRelease(false) + state.withLock { $0.enabled = false } + } + + func snapshotAndReset() -> CmuxSidebarGitRuntimeMetricsSnapshot { + state.withLock { state in + let snapshot = state.snapshot + state = State(enabled: state.enabled) + return snapshot + } + } + + @inline(__always) + func recordSnapshotBatchApply() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.snapshotBatchApplyCount &+= 1 + } + } + + @inline(__always) + func recordMaterialChange() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.materialChangeCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestSeed() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestSeedCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestTraversal() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestTraversalCount &+= 1 + } + } + + @inline(__always) + func recordStaleApply() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.staleApplyCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestRefreshRequest() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestRefreshRequestCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestTaskStarted() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestTaskStartedCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestTaskJoined() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestTaskJoinedCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestRepoFetch() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestRepoFetchCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestStaleCompletionRejectedOffMain() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestStaleCompletionRejectedOffMainCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestMainActorApplyEntered() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestMainActorApplyEnteredCount &+= 1 + } + } + + @inline(__always) + func recordPullRequestFollowUpStarted() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.pullRequestFollowUpStartedCount &+= 1 + } + } + + @inline(__always) + func recordGitStaleCompletionRejectedOffMain() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.gitStaleCompletionRejectedOffMainCount &+= 1 + } + } + + @inline(__always) + func recordGitMainActorApplyEntered() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.gitMainActorApplyEnteredCount &+= 1 + } + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/InitialWorkspaceGitMetadataSnapshot.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/InitialWorkspaceGitMetadataSnapshot.swift index 2e045bb68ff7..9bb218c2ff54 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/InitialWorkspaceGitMetadataSnapshot.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/InitialWorkspaceGitMetadataSnapshot.swift @@ -25,18 +25,20 @@ struct InitialWorkspaceGitMetadataSnapshot: Equatable, Sendable { let indexContentSignature: String? let headSignature: String? let pullRequest: PullRequest + let isCurrent: Bool /// Probes `directory` through `reader` and folds the result into a /// snapshot (branch normalized; PR deferred only when a branch exists). init( probing directory: String, reader: any WorkspaceGitMetadataReading, - trackedPathEventGeneration: GitTrackedPathEventGeneration? = nil + snapshotRequest: GitTrackedChangesSnapshotRequest? = nil ) async { - let metadata = await reader.workspaceMetadata( + let read = await reader.workspaceMetadataSnapshot( for: directory, - trackedPathEventGeneration: trackedPathEventGeneration + snapshotRequest: snapshotRequest ) + let metadata = read.metadata guard metadata.isRepository else { self.init( isRepository: false, @@ -45,7 +47,8 @@ struct InitialWorkspaceGitMetadataSnapshot: Equatable, Sendable { indexSignature: nil, indexContentSignature: nil, headSignature: nil, - pullRequest: .notFound + pullRequest: .notFound, + isCurrent: read.isCurrent ) return } @@ -58,7 +61,8 @@ struct InitialWorkspaceGitMetadataSnapshot: Equatable, Sendable { indexSignature: metadata.indexSignature, indexContentSignature: metadata.indexContentSignature, headSignature: metadata.headSignature, - pullRequest: branch == nil ? .notFound : .deferred + pullRequest: branch == nil ? .notFound : .deferred, + isCurrent: read.isCurrent ) } @@ -69,7 +73,8 @@ struct InitialWorkspaceGitMetadataSnapshot: Equatable, Sendable { indexSignature: String?, indexContentSignature: String?, headSignature: String?, - pullRequest: PullRequest + pullRequest: PullRequest, + isCurrent: Bool ) { self.isRepository = isRepository self.branch = branch @@ -78,5 +83,6 @@ struct InitialWorkspaceGitMetadataSnapshot: Equatable, Sendable { self.indexContentSignature = indexContentSignature self.headSignature = headSignature self.pullRequest = pullRequest + self.isCurrent = isCurrent } } diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotApply.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotApply.swift new file mode 100644 index 000000000000..6f3a9836eaaa --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotApply.swift @@ -0,0 +1,8 @@ +import Foundation + +/// A completed snapshot paired with the task identity that produced it. +struct WorkspaceGitSnapshotApply: Sendable { + let taskID: UUID + let authority: DetachedCompletionAuthority + let snapshot: InitialWorkspaceGitMetadataSnapshot +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotTaskContext.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotTaskContext.swift index 26ea8dd358f2..3bf09b68f95f 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotTaskContext.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Model/WorkspaceGitSnapshotTaskContext.swift @@ -2,5 +2,5 @@ import CmuxGit /// The cache-reuse inputs captured when a per-directory snapshot task starts. nonisolated struct WorkspaceGitSnapshotTaskContext: Equatable, Sendable { - let trackedPathEventGeneration: GitTrackedPathEventGeneration? + let snapshotRequest: GitTrackedChangesSnapshotRequest? } diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Scheduling/WorkspaceGitFallbackCoordinator.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Scheduling/WorkspaceGitFallbackCoordinator.swift new file mode 100644 index 000000000000..b94e8e823307 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Scheduling/WorkspaceGitFallbackCoordinator.swift @@ -0,0 +1,106 @@ +public import Foundation +internal import CmuxGit + +/// One process-wide five-minute fallback clock for every sidebar git service. +/// +/// The composition root injects one coordinator into all windows. A tick +/// allocates one round token and synchronously broadcasts it to every active +/// service, so panels queued behind the shared probe limiter retain the same +/// authority. Weak registrations do not extend a window's lifetime. +@MainActor +public final class WorkspaceGitFallbackCoordinator { + private struct WeakService { + weak var value: SidebarGitMetadataService? + } + + private let clock: any GitPollClock + private let interval: TimeInterval + private var namespace = UUID() + private var generation: UInt64 = 0 + private var services: [UUID: WeakService] = [:] + private var fallbackTask: Task? + + /// Creates an injectable coordinator with one clock and timer. + public init( + clock: any GitPollClock = SystemGitPollClock(), + interval: TimeInterval = 5 * 60 + ) { + self.clock = clock + self.interval = max(0, interval) + } + + func register(_ service: SidebarGitMetadataService, registrationID: UUID) { + services[registrationID] = WeakService(value: service) + serviceStateDidChange() + } + + func unregister(_ registrationID: UUID) { + services.removeValue(forKey: registrationID) + serviceStateDidChange() + } + + func serviceStateDidChange() { + pruneReleasedServices() + let hasActiveService = services.values.contains { + $0.value?.requiresWorkspaceGitFallback == true + } + if hasActiveService { + startTimerIfNeeded() + } else { + fallbackTask?.cancel() + fallbackTask = nil + } + } + + func fireFallbackRound() { + broadcastFallbackRound() + } + + private func startTimerIfNeeded() { + guard fallbackTask == nil else { return } + let clock = clock + let interval = interval + fallbackTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + do { + try await clock.sleep(for: .seconds(interval)) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + self.broadcastFallbackRound() + self.pruneReleasedServices() + guard self.services.values.contains(where: { + $0.value?.requiresWorkspaceGitFallback == true + }) else { + self.fallbackTask = nil + return + } + } + } + } + + private func broadcastFallbackRound() { + pruneReleasedServices() + if generation == .max { + namespace = UUID() + generation = 0 + } else { + generation += 1 + } + let round = GitFallbackRoundID( + namespace: namespace, + sequence: generation + ) + let activeServices = services.values.compactMap(\.value).filter { + $0.requiresWorkspaceGitFallback + } + for service in activeServices { + service.receiveWorkspaceGitFallbackRound(round) + } + } + + private func pruneReleasedServices() { + services = services.filter { $0.value.value != nil } + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/DetachedCompletionAuthority.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/DetachedCompletionAuthority.swift new file mode 100644 index 000000000000..2bcd5ec66a4c --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/DetachedCompletionAuthority.swift @@ -0,0 +1,26 @@ +import os + +/// Revocable authority carried by detached work across the executor boundary. +/// +/// The main-actor owner invalidates the token synchronously when a request is +/// superseded or cancelled. Detached completion checks the same token before +/// hopping to the main actor, so work already known to be obsolete cannot +/// enter apply code. The owner rechecks after the hop to close the race between +/// the off-main check and main-actor execution. +final class DetachedCompletionAuthority: @unchecked Sendable { + let generation: UInt64 + + private let current = OSAllocatedUnfairLock(initialState: true) + + init(generation: UInt64) { + self.generation = generation + } + + func invalidate() { + current.withLock { $0 = false } + } + + func isCurrent() -> Bool { + current.withLock { $0 } + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+Apply.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+Apply.swift index 7652bec6ac1a..565fd8f3ea3c 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+Apply.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+Apply.swift @@ -10,9 +10,11 @@ extension PullRequestPollService { _ results: [WorkspacePullRequestRefreshResult], repoResults: [String: WorkspacePullRequestRepoFetchResult], requestedKeys: [WorkspaceGitProbeKey], + requestedSourceByKey: [WorkspaceGitProbeKey: SourceIdentity] = [:], now: Date, reason: String ) { + runtimeMetricsRecorder.recordPullRequestMainActorApplyEntered() guard let host else { return } guard !host.mobileHostHasRecentActivity(within: mobileHostDeferral.quietInterval) else { workspacePullRequestRefreshTask = nil @@ -46,12 +48,15 @@ extension PullRequestPollService { var needsFollowUpPass = false defer { - if needsFollowUpPass { - let shouldBypassRepoCache = workspacePullRequestFollowUpShouldBypassRepoCache - workspacePullRequestFollowUpShouldBypassRepoCache = false - refreshTrackedWorkspacePullRequestsIfNeeded( - reason: "\(reason).followUp", - allowCachedResultsOverride: shouldBypassRepoCache ? false : nil + let pendingSeedRefresh = takePendingSeedRefresh() + let pendingRefreshRequest = takePendingRefreshRequest() + if needsFollowUpPass || pendingSeedRefresh != nil || pendingRefreshRequest != nil { + let shouldBypassRepoCache = + pendingSeedRefresh?.shouldBypassRepoCache == true + || pendingRefreshRequest?.shouldBypassRepoCache == true + startWorkspacePullRequestFollowUp( + reason: "\(pendingRefreshRequest?.reason ?? reason).followUp", + shouldBypassRepoCache: shouldBypassRepoCache ) } } @@ -69,13 +74,30 @@ extension PullRequestPollService { needsFollowUpPass = true } + if let requestedSource = requestedSourceByKey[key], + workspacePullRequestSourceByKey[key] != requestedSource { + SidebarGitMetadataService.recordStaleApply() + if let currentSource = workspacePullRequestSourceByKey[key] { + if PullRequestProbeService.shouldSkipLookup(branch: currentSource.branch) { + clearWorkspacePullRequestTracking(for: key, preservingSource: true) + } else { + workspacePullRequestNextPollAtByKey[key] = .distantPast + needsFollowUpPass = true + } + } else { + clearWorkspacePullRequestTracking(for: key) + } + continue + } + guard requestedKeySet.contains(key), let result = resultsByKey[key] else { continue } if rerunPending, - workspacePullRequestFollowUpShouldBypassRepoCache, + (workspacePullRequestPendingSeedRefresh?.shouldBypassRepoCache == true || + workspacePullRequestPendingRefreshRequest?.shouldBypassRepoCache == true), result.usedCachedRepoData { continue } @@ -246,6 +268,14 @@ extension PullRequestPollService { // MARK: Tracking bookkeeping func pruneWorkspacePullRequestTracking(validKeys: Set) { + if workspacePullRequestProbeStateByKey.contains(where: { key, state in + guard !validKeys.contains(key) else { return false } + if case .inFlight = state { return true } + return false + }) { + workspacePullRequestRefreshAuthority?.invalidate() + } + workspacePullRequestSourceByKey = workspacePullRequestSourceByKey.filter { validKeys.contains($0.key) } workspacePullRequestNextPollAtByKey = workspacePullRequestNextPollAtByKey.filter { validKeys.contains($0.key) } workspacePullRequestProbeStateByKey = workspacePullRequestProbeStateByKey.filter { validKeys.contains($0.key) } workspacePullRequestLastTerminalStateRefreshAtByKey = workspacePullRequestLastTerminalStateRefreshAtByKey.filter { validKeys.contains($0.key) } @@ -257,7 +287,16 @@ extension PullRequestPollService { updateWorkspacePullRequestPollTimer() } - func clearWorkspacePullRequestTracking(for key: WorkspaceGitProbeKey) { + func clearWorkspacePullRequestTracking( + for key: WorkspaceGitProbeKey, + preservingSource: Bool = false + ) { + if case .inFlight = workspacePullRequestProbeStateByKey[key] { + workspacePullRequestRefreshAuthority?.invalidate() + } + if !preservingSource { + workspacePullRequestSourceByKey.removeValue(forKey: key) + } workspacePullRequestNextPollAtByKey.removeValue(forKey: key) workspacePullRequestProbeStateByKey.removeValue(forKey: key) workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) @@ -272,6 +311,14 @@ extension PullRequestPollService { } public func clearWorkspacePullRequestTracking(workspaceId: UUID) { + if workspacePullRequestProbeStateByKey.contains(where: { key, state in + guard key.workspaceId == workspaceId else { return false } + if case .inFlight = state { return true } + return false + }) { + workspacePullRequestRefreshAuthority?.invalidate() + } + workspacePullRequestSourceByKey = workspacePullRequestSourceByKey.filter { $0.key.workspaceId != workspaceId } workspacePullRequestNextPollAtByKey = workspacePullRequestNextPollAtByKey.filter { $0.key.workspaceId != workspaceId } workspacePullRequestProbeStateByKey = workspacePullRequestProbeStateByKey.filter { $0.key.workspaceId != workspaceId } workspacePullRequestLastTerminalStateRefreshAtByKey = workspacePullRequestLastTerminalStateRefreshAtByKey.filter { $0.key.workspaceId != workspaceId } @@ -294,6 +341,9 @@ extension PullRequestPollService { } public func resetWorkspacePullRequestRefreshState() { + takePendingSeedRefresh() + workspacePullRequestRefreshAuthority?.invalidate() + workspacePullRequestRefreshAuthority = nil workspacePullRequestRefreshTask?.cancel() workspacePullRequestRefreshTask = nil workspacePullRequestProbeStateByKey.removeAll() @@ -301,26 +351,49 @@ extension PullRequestPollService { workspacePullRequestLastTerminalStateRefreshAtByKey.removeAll() workspacePullRequestTransientFailureCountByKey.removeAll() workspacePullRequestRepoCacheBySlug.removeAll() - workspacePullRequestFollowUpShouldBypassRepoCache = false + workspacePullRequestSourceByKey.removeAll() + workspacePullRequestPendingRefreshRequest = nil updateWorkspacePullRequestPollTimer() } + func startWorkspacePullRequestFollowUp( + reason: String, + shouldBypassRepoCache: Bool + ) { + runtimeMetricsRecorder.recordPullRequestFollowUpStarted() + refreshTrackedWorkspacePullRequestsIfNeeded( + reason: reason, + allowCachedResultsOverride: shouldBypassRepoCache ? false : nil + ) + } + // MARK: Rerun flags func markWorkspacePullRequestProbeRerunPending( for key: WorkspaceGitProbeKey, + reason: String, bypassRepoCache: Bool ) { + // A rerun means the active result no longer has mutation authority. + // Invalidate synchronously so detached completion can reject it before + // hopping to the main actor. + workspacePullRequestRefreshAuthority?.invalidate() guard case .inFlight(let rerunPending) = workspacePullRequestProbeStateByKey[key], !rerunPending else { if bypassRepoCache { - workspacePullRequestFollowUpShouldBypassRepoCache = true + queueWorkspacePullRequestRefreshFollowUp( + reason: reason, + shouldBypassRepoCache: true + ) } return } workspacePullRequestProbeStateByKey[key] = .inFlight(rerunPending: true) if bypassRepoCache { - workspacePullRequestFollowUpShouldBypassRepoCache = true + queueWorkspacePullRequestRefreshFollowUp( + reason: reason, + shouldBypassRepoCache: true + ) } } diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+RefreshExecution.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+RefreshExecution.swift new file mode 100644 index 000000000000..ee1771b9008e --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+RefreshExecution.swift @@ -0,0 +1,150 @@ +import Foundation +import CmuxGit + +// MARK: - Detached pull-request lookup execution and main-actor apply. + +extension PullRequestPollService { + struct PendingRefreshRequest { + var reason: String + var shouldBypassRepoCache: Bool + } + + struct RefreshRequest { + let seeds: [WorkspacePullRequestCandidateSeed] + let keys: [WorkspaceGitProbeKey] + let sources: [WorkspaceGitProbeKey: SourceIdentity] + let cacheBySlug: [String: WorkspacePullRequestRepoCacheEntry] + let now: Date + let allowCachedResults: Bool + let reason: String + } + + func startWorkspacePullRequestRefresh(_ request: RefreshRequest) { + workspacePullRequestRefreshGeneration &+= 1 + let authority = DetachedCompletionAuthority( + generation: workspacePullRequestRefreshGeneration + ) + workspacePullRequestRefreshAuthority = authority + runtimeMetricsRecorder.recordPullRequestTaskStarted() + + let refreshExecutor = refreshExecutor + let runtimeMetricsRecorder = runtimeMetricsRecorder + workspacePullRequestRefreshTask = Task.detached(priority: .utility) { [weak self] in + let candidateResolution = await refreshExecutor.resolveCandidateSeeds(request.seeds) + guard authority.isCurrent() else { + runtimeMetricsRecorder.recordPullRequestStaleCompletionRejectedOffMain() + await self?.discardRejectedWorkspacePullRequestRefresh( + authority: authority, + request: request + ) + return + } + guard !Task.isCancelled else { return } + + if !candidateResolution.repoDirectoriesBySlug.isEmpty { + runtimeMetricsRecorder.recordPullRequestRepoFetch() + } + let repoResults = await refreshExecutor.fetchRepoResults( + candidateResolution: candidateResolution, + cacheBySlug: request.cacheBySlug, + now: request.now, + allowCachedResults: request.allowCachedResults + ) + let results = PullRequestProbeService.resolveRefreshResults( + candidates: candidateResolution.candidates, + repoResults: repoResults + ) + guard authority.isCurrent() else { + runtimeMetricsRecorder.recordPullRequestStaleCompletionRejectedOffMain() + await self?.discardRejectedWorkspacePullRequestRefresh( + authority: authority, + request: request + ) + return + } + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self, !Task.isCancelled else { return } + guard authority.isCurrent(), + self.workspacePullRequestRefreshAuthority === authority else { + self.discardRejectedWorkspacePullRequestRefresh( + authority: authority, + request: request + ) + return + } + self.workspacePullRequestRefreshTask = nil + self.workspacePullRequestRefreshAuthority = nil + self.applyWorkspacePullRequestRefreshResults( + results, + repoResults: repoResults, + requestedKeys: request.keys, + requestedSourceByKey: request.sources, + now: Date(), + reason: request.reason + ) + } + } + } + + /// Drains an invalidated completion without entering the apply path. + /// Exactly one global follow-up owns every rerun and pending seed that + /// accumulated while the rejected fetch was in flight. + func discardRejectedWorkspacePullRequestRefresh( + authority: DetachedCompletionAuthority, + request: RefreshRequest + ) { + guard workspacePullRequestRefreshAuthority === authority else { return } + workspacePullRequestRefreshTask = nil + workspacePullRequestRefreshAuthority = nil + + var needsFollowUp = false + for key in request.keys { + let rerunPending = workspacePullRequestProbeRerunPending(for: key) + workspacePullRequestProbeStateByKey[key] = .idle + + if rerunPending || workspacePullRequestSourceByKey[key] != request.sources[key] { + workspacePullRequestNextPollAtByKey[key] = .distantPast + needsFollowUp = true + } + } + + let pendingSeedRefresh = takePendingSeedRefresh() + let pendingRefreshRequest = takePendingRefreshRequest() + guard needsFollowUp || pendingSeedRefresh != nil || pendingRefreshRequest != nil else { + updateWorkspacePullRequestPollTimer() + return + } + + let shouldBypassRepoCache = + pendingSeedRefresh?.shouldBypassRepoCache == true + || pendingRefreshRequest?.shouldBypassRepoCache == true + startWorkspacePullRequestFollowUp( + reason: "\(pendingRefreshRequest?.reason ?? request.reason).followUp", + shouldBypassRepoCache: shouldBypassRepoCache + ) + } + + func queueWorkspacePullRequestRefreshFollowUp( + reason: String, + shouldBypassRepoCache: Bool + ) { + if var pending = workspacePullRequestPendingRefreshRequest { + pending.reason = reason + pending.shouldBypassRepoCache = + pending.shouldBypassRepoCache || shouldBypassRepoCache + workspacePullRequestPendingRefreshRequest = pending + } else { + workspacePullRequestPendingRefreshRequest = PendingRefreshRequest( + reason: reason, + shouldBypassRepoCache: shouldBypassRepoCache + ) + } + } + + @discardableResult + func takePendingRefreshRequest() -> PendingRefreshRequest? { + defer { workspacePullRequestPendingRefreshRequest = nil } + return workspacePullRequestPendingRefreshRequest + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+SourceSeeding.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+SourceSeeding.swift new file mode 100644 index 000000000000..771a8018191d --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService+SourceSeeding.swift @@ -0,0 +1,123 @@ +public import Foundation +import CmuxGit + +// MARK: - O(1) local-git source ownership and same-turn refresh coalescing. + +extension PullRequestPollService { + struct SourceIdentity: Equatable { + let directory: String + let branch: String + } + + struct PendingSeedRefresh { + let reason: String + var shouldBypassRepoCache: Bool + } + + /// Seeds polling for a local git snapshot when its normalized source + /// differs from the source this panel already owns. + /// + /// - Parameters: + /// - workspaceId: Workspace containing the panel. + /// - panelId: Panel whose source was observed. + /// - directory: Local repository directory. + /// - branch: Current checked-out branch. + /// - reason: Diagnostic reason carried into the coalesced refresh pass. + public func seedWorkspacePullRequestRefreshIfNeeded( + workspaceId: UUID, + panelId: UUID, + directory: String, + branch: String, + reason: String + ) { + runtimeMetricsRecorder.recordPullRequestSeed() + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + guard sidebarPullRequestPollingEnabled else { + clearWorkspacePullRequestMetadata(for: key) + return + } + guard let normalizedBranch = GitMetadataService.normalizedBranchName(branch) else { + clearWorkspacePullRequestMetadata(for: key) + return + } + let source = SourceIdentity( + directory: directory.normalizedGitProbeDirectory, + branch: normalizedBranch + ) + guard workspacePullRequestSourceByKey[key] != source else { + return + } + workspacePullRequestSourceByKey[key] = source + + if PullRequestProbeService.shouldSkipLookup(branch: normalizedBranch) { + if host?.panelPullRequestBadge(workspaceId: workspaceId, panelId: panelId) != nil { + host?.clearPanelPullRequest(workspaceId: workspaceId, panelId: panelId) + } + clearWorkspacePullRequestTracking(for: key, preservingSource: true) + return + } + + let shouldBypassRepoCache = !PullRequestProbeService.refreshAllowsRepoCache(reason: reason) + if case .inFlight = workspacePullRequestProbeStateByKey[key] { + markWorkspacePullRequestProbeRerunPending( + for: key, + reason: reason, + bypassRepoCache: shouldBypassRepoCache + ) + } else { + workspacePullRequestNextPollAtByKey[key] = .distantPast + } +#if DEBUG + debugLog( + "workspace.prRefresh.seed workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=\(reason)" + ) +#endif + queueWorkspacePullRequestSeedRefresh( + reason: reason, + shouldBypassRepoCache: shouldBypassRepoCache + ) + } + + private func queueWorkspacePullRequestSeedRefresh( + reason: String, + shouldBypassRepoCache: Bool + ) { + if var pending = workspacePullRequestPendingSeedRefresh { + pending.shouldBypassRepoCache = pending.shouldBypassRepoCache || shouldBypassRepoCache + workspacePullRequestPendingSeedRefresh = pending + } else { + workspacePullRequestPendingSeedRefresh = PendingSeedRefresh( + reason: reason, + shouldBypassRepoCache: shouldBypassRepoCache + ) + } + // The in-flight apply owns the single global follow-up. Arming another + // yielding task here lets both paths traverse the whole host tree. + guard workspacePullRequestRefreshTask == nil else { return } + guard workspacePullRequestSeedRefreshTask == nil else { return } + workspacePullRequestSeedRefreshTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self, !Task.isCancelled else { return } + self.workspacePullRequestSeedRefreshTask = nil + // A timer/force refresh may have started while this task yielded. + // Its apply consumes the still-pending seed and owns the follow-up. + guard self.workspacePullRequestRefreshTask == nil, + let pending = self.takePendingSeedRefresh() else { + return + } + self.refreshTrackedWorkspacePullRequestsIfNeeded( + reason: pending.reason, + allowCachedResultsOverride: pending.shouldBypassRepoCache ? false : nil + ) + } + } + + @discardableResult + func takePendingSeedRefresh() -> PendingSeedRefresh? { + workspacePullRequestSeedRefreshTask?.cancel() + workspacePullRequestSeedRefreshTask = nil + defer { workspacePullRequestPendingSeedRefresh = nil } + return workspacePullRequestPendingSeedRefresh + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift index 15f0c5cf2464..6f932afdf3fe 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestPollService.swift @@ -32,16 +32,19 @@ public final class PullRequestPollService: PullRequestProbing { // MARK: Dependencies - // Resolves slugs for candidate seeds (stateless CmuxGit reader). - let gitMetadataService: GitMetadataService - // Fetches and matches GitHub PRs (stateless CmuxGit pipeline). - let probeService: PullRequestProbeService + // Runs slug resolution and GitHub fetches off-main. The production + // executor wraps the stateless CmuxGit services; tests inject a gated + // executor at this exact remote-fetch boundary. + let refreshExecutor: any PullRequestRefreshExecuting // Drives the poll deadline and mobile-host deferral sleeps. let clock: any GitPollClock // Mobile-host background-work deferral intervals. let mobileHostDeferral: MobileHostDeferralPolicy // Debug diagnostics sink (the app injects its debug logger in DEBUG). let debugLog: @Sendable (String) -> Void + // Defaults to the process-wide opt-in recorder. Focused tests replace it + // with an isolated enabled instance so causal counts stay deterministic. + var runtimeMetricsRecorder = SidebarGitMetadataService.runtimeMetrics // The window-side seam; set once via attach(host:). Weak: the host owns // this service. weak var host: (any SidebarGitHosting)? @@ -55,7 +58,12 @@ public final class PullRequestPollService: PullRequestProbing { var workspacePullRequestRepoCacheBySlug: [String: WorkspacePullRequestRepoCacheEntry] = [:] var workspacePullRequestPollTask: Task? var workspacePullRequestRefreshTask: Task? - var workspacePullRequestFollowUpShouldBypassRepoCache = false + var workspacePullRequestRefreshAuthority: DetachedCompletionAuthority? + var workspacePullRequestRefreshGeneration: UInt64 = 0 + var workspacePullRequestPendingRefreshRequest: PendingRefreshRequest? + var workspacePullRequestSourceByKey: [WorkspaceGitProbeKey: SourceIdentity] = [:] + var workspacePullRequestSeedRefreshTask: Task? + var workspacePullRequestPendingSeedRefresh: PendingSeedRefresh? var lastSidebarPullRequestPollingEnabled = false /// Creates the poll service. @@ -73,16 +81,32 @@ public final class PullRequestPollService: PullRequestProbing { mobileHostDeferral: MobileHostDeferralPolicy = .standard, debugLog: @escaping @Sendable (String) -> Void = { _ in } ) { - self.gitMetadataService = gitMetadataService - self.probeService = probeService + self.refreshExecutor = LivePullRequestRefreshExecutor( + gitMetadataService: gitMetadataService, + probeService: probeService + ) + self.clock = clock + self.mobileHostDeferral = mobileHostDeferral + self.debugLog = debugLog + } + + init( + refreshExecutor: any PullRequestRefreshExecuting, + clock: any GitPollClock = SystemGitPollClock(), + mobileHostDeferral: MobileHostDeferralPolicy = .standard, + debugLog: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.refreshExecutor = refreshExecutor self.clock = clock self.mobileHostDeferral = mobileHostDeferral self.debugLog = debugLog } deinit { + workspacePullRequestRefreshAuthority?.invalidate() workspacePullRequestPollTask?.cancel() workspacePullRequestRefreshTask?.cancel() + workspacePullRequestSeedRefreshTask?.cancel() } /// Wires the host and captures the initial polling-setting value @@ -151,13 +175,47 @@ public final class PullRequestPollService: PullRequestProbing { // MARK: Refresh pass public func refreshTrackedWorkspacePullRequestsIfNeeded(reason: String) { - refreshTrackedWorkspacePullRequestsIfNeeded(reason: reason, allowCachedResultsOverride: nil) + // Equivalent periodic requests join the active fetch in O(1). A + // source seed or cache-bypassing request must still traverse so it can + // invalidate stale authority and own the single follow-up. + if workspacePullRequestRefreshTask != nil, + sidebarPullRequestPollingEnabled, + host?.mobileHostHasRecentActivity(within: mobileHostDeferral.quietInterval) != true, + workspacePullRequestPendingSeedRefresh == nil { + if !PullRequestProbeService.refreshAllowsRepoCache(reason: reason) { + queueWorkspacePullRequestRefreshFollowUp( + reason: reason, + shouldBypassRepoCache: true + ) + } + runtimeMetricsRecorder.recordPullRequestRefreshRequest() + runtimeMetricsRecorder.recordPullRequestTaskJoined() + return + } + // If another refresh is already running, its apply owns the pending + // seed and the one follow-up pass. Consuming it here would lose a seed + // for a panel outside the current request batch when this traversal + // reaches the in-flight-task guard below. + let pendingSeedRefresh = workspacePullRequestRefreshTask == nil + ? takePendingSeedRefresh() + : workspacePullRequestPendingSeedRefresh + let pendingRefreshRequest = workspacePullRequestRefreshTask == nil + ? takePendingRefreshRequest() + : workspacePullRequestPendingRefreshRequest + refreshTrackedWorkspacePullRequestsIfNeeded( + reason: pendingRefreshRequest?.reason ?? reason, + allowCachedResultsOverride: ( + pendingSeedRefresh?.shouldBypassRepoCache == true || + pendingRefreshRequest?.shouldBypassRepoCache == true + ) ? false : nil + ) } func refreshTrackedWorkspacePullRequestsIfNeeded( reason: String, allowCachedResultsOverride: Bool? ) { + runtimeMetricsRecorder.recordPullRequestRefreshRequest() guard let host else { return } guard !host.mobileHostHasRecentActivity(within: mobileHostDeferral.quietInterval) else { deferWorkspacePullRequestRefreshForMobileHost() @@ -168,10 +226,12 @@ public final class PullRequestPollService: PullRequestProbing { host.clearAllSidebarPullRequestMetadata() return } + runtimeMetricsRecorder.recordPullRequestTraversal() let now = Date() var candidateSeeds: [WorkspacePullRequestCandidateSeed] = [] var requestedKeys: [WorkspaceGitProbeKey] = [] + var requestedSourceByKey: [WorkspaceGitProbeKey: SourceIdentity] = [:] var validKeys: Set = [] for workspaceId in host.orderedWorkspaceIds() { @@ -186,13 +246,34 @@ public final class PullRequestPollService: PullRequestProbing { ?? host.panelPullRequestBadge(workspaceId: workspaceId, panelId: panelId)?.branch ) guard let branch else { + workspacePullRequestSourceByKey.removeValue(forKey: key) clearWorkspacePullRequestTracking(for: key) continue } + let source = SourceIdentity( + directory: host.gitProbeDirectory(workspaceId: workspaceId, panelId: panelId)? + .normalizedGitProbeDirectory ?? "", + branch: branch + ) + let previousSource = workspacePullRequestSourceByKey[key] + workspacePullRequestSourceByKey[key] = source + if PullRequestProbeService.shouldSkipLookup(branch: branch) { - host.clearPanelPullRequest(workspaceId: workspaceId, panelId: panelId) - clearWorkspacePullRequestTracking(for: key) + if host.panelPullRequestBadge(workspaceId: workspaceId, panelId: panelId) != nil { + host.clearPanelPullRequest(workspaceId: workspaceId, panelId: panelId) + } + clearWorkspacePullRequestTracking(for: key, preservingSource: true) + continue + } + + if case .inFlight = workspacePullRequestProbeStateByKey[key], + previousSource != source { + markWorkspacePullRequestProbeRerunPending( + for: key, + reason: reason, + bypassRepoCache: !PullRequestProbeService.refreshAllowsRepoCache(reason: reason) + ) continue } @@ -205,10 +286,14 @@ public final class PullRequestPollService: PullRequestProbing { } if case .inFlight = workspacePullRequestProbeStateByKey[key] { - markWorkspacePullRequestProbeRerunPending( - for: key, - bypassRepoCache: !PullRequestProbeService.refreshAllowsRepoCache(reason: reason) - ) + let bypassesRepoCache = !PullRequestProbeService.refreshAllowsRepoCache(reason: reason) + if bypassesRepoCache { + markWorkspacePullRequestProbeRerunPending( + for: key, + reason: reason, + bypassRepoCache: bypassesRepoCache + ) + } continue } @@ -220,6 +305,7 @@ public final class PullRequestPollService: PullRequestProbing { ) candidateSeeds.append(candidateSeed) requestedKeys.append(key) + requestedSourceByKey[key] = source } } @@ -242,44 +328,18 @@ public final class PullRequestPollService: PullRequestProbing { workspacePullRequestProbeStateByKey[key] = .inFlight(rerunPending: false) } - let cacheBySlug = workspacePullRequestRepoCacheBySlug - let allowCachedResults = allowCachedResultsOverride - ?? PullRequestProbeService.refreshAllowsRepoCache(reason: reason) - let gitMetadataService = gitMetadataService - let probeService = probeService - let seeds = candidateSeeds - let keys = requestedKeys - workspacePullRequestRefreshTask = Task.detached(priority: .utility) { [weak self] in - let candidateResolution = await probeService.resolveCandidateSeeds( - seeds, - gitMetadata: gitMetadataService - ) - guard !Task.isCancelled else { return } - let repoResults = await probeService.fetchRepoResults( - repoDirectoriesBySlug: candidateResolution.repoDirectoriesBySlug, - candidateBranchesByRepo: candidateResolution.candidateBranchesByRepo, - cacheBySlug: cacheBySlug, + startWorkspacePullRequestRefresh( + RefreshRequest( + seeds: candidateSeeds, + keys: requestedKeys, + sources: requestedSourceByKey, + cacheBySlug: workspacePullRequestRepoCacheBySlug, now: now, - allowCachedResults: allowCachedResults + allowCachedResults: allowCachedResultsOverride + ?? PullRequestProbeService.refreshAllowsRepoCache(reason: reason), + reason: reason ) - let results = PullRequestProbeService.resolveRefreshResults( - candidates: candidateResolution.candidates, - repoResults: repoResults - ) - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - guard let self else { return } - guard !Task.isCancelled else { return } - self.workspacePullRequestRefreshTask = nil - self.applyWorkspacePullRequestRefreshResults( - results, - repoResults: repoResults, - requestedKeys: keys, - now: Date(), - reason: reason - ) - } - } + ) } func shouldRefreshWorkspacePullRequest( @@ -306,12 +366,16 @@ public final class PullRequestPollService: PullRequestProbing { return } let shouldBypassRepoCache = !PullRequestProbeService.refreshAllowsRepoCache(reason: reason) - if shouldBypassRepoCache, workspacePullRequestRefreshTask != nil { - workspacePullRequestFollowUpShouldBypassRepoCache = true + if workspacePullRequestRefreshTask != nil { + queueWorkspacePullRequestRefreshFollowUp( + reason: reason, + shouldBypassRepoCache: shouldBypassRepoCache + ) } if case .inFlight = workspacePullRequestProbeStateByKey[key] { markWorkspacePullRequestProbeRerunPending( for: key, + reason: reason, bypassRepoCache: shouldBypassRepoCache ) } else { diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestProbing.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestProbing.swift index f8bbf2c7b911..b2a9bffc06e5 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestProbing.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestProbing.swift @@ -16,6 +16,23 @@ public protocol PullRequestProbing: AnyObject { /// Requests a refresh for one panel (marks its poll deadline immediate, /// or queues a rerun if a refresh is already in flight). func scheduleWorkspacePullRequestRefresh(workspaceId: UUID, panelId: UUID, reason: String) + /// Seeds polling from a local git snapshot. An unchanged normalized + /// directory/branch source is a no-op; changed sources are coalesced into + /// one refresh pass for the current main-actor turn. + /// + /// - Parameters: + /// - workspaceId: Workspace containing the panel. + /// - panelId: Panel whose source was observed. + /// - directory: Local repository directory. + /// - branch: Current checked-out branch. + /// - reason: Diagnostic reason for the refresh request. + func seedWorkspacePullRequestRefreshIfNeeded( + workspaceId: UUID, + panelId: UUID, + directory: String, + branch: String, + reason: String + ) /// Runs one refresh pass over every tracked panel whose deadline is due. func refreshTrackedWorkspacePullRequestsIfNeeded(reason: String) /// Reacts to the pull-request polling setting toggling. diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestRefreshExecuting.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestRefreshExecuting.swift new file mode 100644 index 000000000000..4f9b24a203c5 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/PullRequestRefreshExecuting.swift @@ -0,0 +1,49 @@ +import Foundation +import CmuxGit + +/// The detached stages of one pull-request refresh. +/// +/// Keeping these stages behind a `Sendable` executor lets the main-actor poll +/// owner retain sole authority over scheduling and application while tests +/// deterministically gate the actual repository-fetch boundary. +protocol PullRequestRefreshExecuting: Sendable { + func resolveCandidateSeeds( + _ seeds: [WorkspacePullRequestCandidateSeed] + ) async -> WorkspacePullRequestCandidateResolution + + func fetchRepoResults( + candidateResolution: WorkspacePullRequestCandidateResolution, + cacheBySlug: [String: WorkspacePullRequestRepoCacheEntry], + now: Date, + allowCachedResults: Bool + ) async -> [String: WorkspacePullRequestRepoFetchResult] +} + +struct LivePullRequestRefreshExecutor: PullRequestRefreshExecuting { + let gitMetadataService: GitMetadataService + let probeService: PullRequestProbeService + + func resolveCandidateSeeds( + _ seeds: [WorkspacePullRequestCandidateSeed] + ) async -> WorkspacePullRequestCandidateResolution { + await probeService.resolveCandidateSeeds( + seeds, + gitMetadata: gitMetadataService + ) + } + + func fetchRepoResults( + candidateResolution: WorkspacePullRequestCandidateResolution, + cacheBySlug: [String: WorkspacePullRequestRepoCacheEntry], + now: Date, + allowCachedResults: Bool + ) async -> [String: WorkspacePullRequestRepoFetchResult] { + await probeService.fetchRepoResults( + repoDirectoriesBySlug: candidateResolution.repoDirectoriesBySlug, + candidateBranchesByRepo: candidateResolution.candidateBranchesByRepo, + cacheBySlug: cacheBySlug, + now: now, + allowCachedResults: allowCachedResults + ) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Probe.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Probe.swift index 61344d5fd429..7d304a75cf4f 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Probe.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Probe.swift @@ -24,7 +24,8 @@ extension SidebarGitMetadataService { workspaceId: UUID, panelId: UUID, reason: String, - delays: [TimeInterval] = [0] + delays: [TimeInterval] = [0], + snapshotRequest: GitTrackedChangesSnapshotRequest? = nil ) { let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) guard let host else { return } @@ -43,7 +44,8 @@ extension SidebarGitMetadataService { panelId: panelId, directory: directory, delays: delays, - reason: reason + reason: reason, + snapshotRequest: snapshotRequest ) } @@ -52,10 +54,26 @@ extension SidebarGitMetadataService { panelId: UUID, directory: String, delays: [TimeInterval], - reason: String + reason: String, + snapshotRequest: GitTrackedChangesSnapshotRequest? ) { let normalizedDirectory = directory.normalizedGitProbeDirectory let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + if let previousDirectory = workspaceGitSnapshotDirectoryByProbeKey[key], + previousDirectory != normalizedDirectory { + removeWorkspaceGitSnapshotRequest(for: key) + } + let taskContext = WorkspaceGitSnapshotTaskContext( + snapshotRequest: snapshotRequestForSnapshot( + directory: normalizedDirectory, + reason: reason, + fallbackRequest: snapshotRequest + ) + ) + supersedeWorkspaceGitSnapshotTask( + directory: normalizedDirectory, + with: taskContext + ) cancelWorkspaceGitProbeTask(for: key) if workspaceGitProbeStateByKey[key] == nil { workspaceGitProbeStateByKey[key] = .idle @@ -87,7 +105,7 @@ extension SidebarGitMetadataService { probeKey: key, expectedDirectory: normalizedDirectory, isLastAttempt: isLastAttempt, - reason: reason + taskContext: taskContext ) } } @@ -97,7 +115,7 @@ extension SidebarGitMetadataService { probeKey: WorkspaceGitProbeKey, expectedDirectory: String, isLastAttempt: Bool, - reason: String + taskContext: WorkspaceGitSnapshotTaskContext ) { guard host?.mobileHostHasRecentActivity(within: mobileHostDeferral.quietInterval) != true else { workspaceGitProbeStateByKey[probeKey] = .idle @@ -118,6 +136,10 @@ extension SidebarGitMetadataService { workspaceGitProbeStateByKey[probeKey] = .inFlight(rerunPending: false) case .inFlight: markWorkspaceGitProbeRerunPending(for: probeKey) + supersedeWorkspaceGitSnapshotTask( + directory: expectedDirectory, + with: taskContext + ) return } @@ -125,7 +147,7 @@ extension SidebarGitMetadataService { probeKey: probeKey, expectedDirectory: expectedDirectory, isLastAttempt: isLastAttempt, - reason: reason + taskContext: taskContext ) } @@ -133,7 +155,7 @@ extension SidebarGitMetadataService { probeKey: WorkspaceGitProbeKey, expectedDirectory: String, isLastAttempt: Bool, - reason: String + taskContext: WorkspaceGitSnapshotTaskContext ) { let request = WorkspaceGitSnapshotProbeRequest( probeKey: probeKey, @@ -145,15 +167,12 @@ extension SidebarGitMetadataService { } workspaceGitSnapshotDirectoryByProbeKey[probeKey] = expectedDirectory workspaceGitSnapshotRequestsByDirectory[expectedDirectory, default: [:]][probeKey] = request - let taskContext = WorkspaceGitSnapshotTaskContext( - trackedPathEventGeneration: trackedPathEventGenerationForSnapshot( - directory: expectedDirectory, - reason: reason - ) - ) guard workspaceGitSnapshotTasksByDirectory[expectedDirectory] == nil else { if workspaceGitSnapshotTaskContextByDirectory[expectedDirectory] != taskContext { - markWorkspaceGitSnapshotRerunPending(directory: expectedDirectory) + supersedeWorkspaceGitSnapshotTask( + directory: expectedDirectory, + with: taskContext + ) } #if DEBUG debugLog( @@ -166,7 +185,15 @@ extension SidebarGitMetadataService { let reader = workspaceGitMetadataReader let probeLimiter = probeLimiter + let taskID = UUID() + workspaceGitSnapshotCompletionGeneration &+= 1 + let authority = DetachedCompletionAuthority( + generation: workspaceGitSnapshotCompletionGeneration + ) workspaceGitSnapshotTaskContextByDirectory[expectedDirectory] = taskContext + workspaceGitSnapshotTaskIDByDirectory[expectedDirectory] = taskID + workspaceGitSnapshotCompletionAuthorityByDirectory[expectedDirectory] = authority + let runtimeMetricsRecorder = runtimeMetricsRecorder workspaceGitSnapshotTasksByDirectory[expectedDirectory] = Task.detached(priority: .utility) { [weak self] in let didAcquirePermit = await probeLimiter.acquire() guard didAcquirePermit else { return } @@ -180,26 +207,93 @@ extension SidebarGitMetadataService { let snapshot = await InitialWorkspaceGitMetadataSnapshot( probing: expectedDirectory, reader: reader, - trackedPathEventGeneration: taskContext.trackedPathEventGeneration + snapshotRequest: taskContext.snapshotRequest ) + guard snapshot.isCurrent, authority.isCurrent() else { + runtimeMetricsRecorder.recordGitStaleCompletionRejectedOffMain() + await self?.discardRejectedWorkspaceGitMetadataSnapshot( + taskID: taskID, + authority: authority, + expectedDirectory: expectedDirectory + ) + return + } guard !Task.isCancelled else { return } await MainActor.run { [weak self] in guard !Task.isCancelled else { return } - self?.applyWorkspaceGitMetadataSnapshotBatch( + guard let self else { return } + guard authority.isCurrent(), + self.workspaceGitSnapshotTaskIDByDirectory[expectedDirectory] == taskID, + self.workspaceGitSnapshotCompletionAuthorityByDirectory[expectedDirectory] === authority else { + self.discardRejectedWorkspaceGitMetadataSnapshot( + taskID: taskID, + authority: authority, + expectedDirectory: expectedDirectory + ) + return + } + self.enqueueWorkspaceGitMetadataSnapshotBatch( snapshot, + taskID: taskID, + authority: authority, expectedDirectory: expectedDirectory ) } } } + private func enqueueWorkspaceGitMetadataSnapshotBatch( + _ snapshot: InitialWorkspaceGitMetadataSnapshot, + taskID: UUID, + authority: DetachedCompletionAuthority, + expectedDirectory: String + ) { + let apply = WorkspaceGitSnapshotApply( + taskID: taskID, + authority: authority, + snapshot: snapshot + ) + workspaceGitSnapshotApplyBatcher.submit(apply, for: expectedDirectory) { [weak self] snapshots in + guard let self else { return } + Self.recordSnapshotBatchApply() + // Stable ordering keeps state-machine transitions and test traces + // deterministic when multiple repositories finish in one frame. + for directory in snapshots.keys.sorted() { + guard let apply = snapshots[directory] else { continue } + guard apply.authority.isCurrent(), + self.workspaceGitSnapshotTaskIDByDirectory[directory] == apply.taskID, + self.workspaceGitSnapshotCompletionAuthorityByDirectory[directory] === apply.authority else { + self.discardRejectedWorkspaceGitMetadataSnapshot( + taskID: apply.taskID, + authority: apply.authority, + expectedDirectory: directory + ) + continue + } + self.applyWorkspaceGitMetadataSnapshotBatch( + apply.snapshot, + taskID: apply.taskID, + expectedDirectory: directory + ) + } + } + } + private func applyWorkspaceGitMetadataSnapshotBatch( _ snapshot: InitialWorkspaceGitMetadataSnapshot, + taskID: UUID, expectedDirectory: String ) { + runtimeMetricsRecorder.recordGitMainActorApplyEntered() + workspaceGitSnapshotCompletionAuthorityByDirectory.removeValue(forKey: expectedDirectory) + let pendingContext = workspaceGitSnapshotPendingContextByDirectory.removeValue( + forKey: expectedDirectory + ) workspaceGitSnapshotTasksByDirectory.removeValue(forKey: expectedDirectory) workspaceGitSnapshotTaskContextByDirectory.removeValue(forKey: expectedDirectory) + workspaceGitSnapshotTaskIDByDirectory.removeValue(forKey: expectedDirectory) let requests = workspaceGitSnapshotRequestsByDirectory.removeValue(forKey: expectedDirectory) ?? [:] + _ = pendingContext for request in requests.values { workspaceGitSnapshotDirectoryByProbeKey.removeValue(forKey: request.probeKey) applyWorkspaceGitMetadataSnapshot( @@ -211,273 +305,48 @@ extension SidebarGitMetadataService { } } - func cancelWorkspaceGitProbeTask(for key: WorkspaceGitProbeKey) { - workspaceGitProbeTasksByKey.removeValue(forKey: key)?.cancel() - } - - private func applyWorkspaceGitMetadataSnapshot( - _ snapshot: InitialWorkspaceGitMetadataSnapshot, - probeKey: WorkspaceGitProbeKey, - expectedDirectory: String, - isLastAttempt: Bool + /// Drains an invalidated snapshot and schedules one replacement per joined + /// panel without entering snapshot apply. + func discardRejectedWorkspaceGitMetadataSnapshot( + taskID: UUID, + authority: DetachedCompletionAuthority, + expectedDirectory: String ) { - let wasInFlight: Bool = { - if case .inFlight = workspaceGitProbeStateByKey[probeKey] { return true } - return false - }() - guard host?.mobileHostHasRecentActivity(within: mobileHostDeferral.quietInterval) != true else { - workspaceGitProbeStateByKey[probeKey] = .idle - scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId, - reason: "mobileHostDeferred", - delays: [max( - mobileHostDeferral.deferralInterval, - host?.mobileHostQuietDelay(for: mobileHostDeferral.quietInterval) ?? 0 - )] - ) - return - } - let shouldTrackPullRequests = sidebarPullRequestPollingEnabled - let resolvedPullRequest: SidebarPullRequestBadge? = { - guard shouldTrackPullRequests else { return nil } - guard case .resolved(let pullRequest) = snapshot.pullRequest else { return nil } - return pullRequest - }() - let shouldTrackGitDirectory = snapshot.isRepository || resolvedPullRequest != nil - let shouldFinishProbe = shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt - let shouldStopTrackingGitDirectory = shouldFinishProbe && !shouldTrackGitDirectory - var didClearProbe = false - defer { - if wasInFlight, !didClearProbe { - let rerunPending = workspaceGitProbeRerunPending(for: probeKey) - if rerunPending { - workspaceGitProbeStateByKey[probeKey] = .idle - if shouldFinishProbe { - cancelWorkspaceGitProbeTask(for: probeKey) - } - scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId, - reason: "rerunPending" - ) - } else if shouldStopTrackingGitDirectory { - clearWorkspaceGitProbe(probeKey) - } else if shouldFinishProbe { - finishWorkspaceGitProbeAttempt(probeKey) - } else { - workspaceGitProbeStateByKey[probeKey] = .idle - } - } - } - - guard wasInFlight else { return } - guard let host, host.workspaceExists(probeKey.workspaceId) else { - clearWorkspaceGitProbe(probeKey) - didClearProbe = true - return - } - guard host.panelExists(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) else { - clearWorkspaceGitProbe(probeKey) - didClearProbe = true - return - } - if host.shouldSkipLocalGitMetadata(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) { - clearWorkspaceGitProbeTracking(for: probeKey) - didClearProbe = true + guard workspaceGitSnapshotTaskIDByDirectory[expectedDirectory] == taskID, + workspaceGitSnapshotCompletionAuthorityByDirectory[expectedDirectory] === authority else { return } - guard let currentDirectory = host.gitProbeDirectory( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId - ) else { - clearWorkspaceGitProbe(probeKey) - didClearProbe = true - return - } - if currentDirectory != expectedDirectory { - clearWorkspaceGitProbe(probeKey) - didClearProbe = true -#if DEBUG - debugLog( - "workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + - "panel=\(probeKey.panelId.uuidString.prefix(5)) reason=directoryChanged " + - "expected=\(expectedDirectory) current=\(currentDirectory)" - ) -#endif - return - } - - host.updatePanelDirectory( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId, - directory: expectedDirectory, - displayLabel: nil - ) - - if shouldTrackGitDirectory { - workspaceGitTrackedDirectoryByKey[probeKey] = expectedDirectory - updateWorkspaceGitMetadataWatcher(for: probeKey, directory: expectedDirectory) - } else { - workspaceGitTrackedDirectoryByKey.removeValue(forKey: probeKey) - stopWorkspaceGitMetadataWatcher(for: probeKey) - } - updateWorkspaceGitMetadataFallbackTimer() - - let previousBranchState = host.panelGitBranch( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId - ) - let previousPullRequestBadge = host.panelPullRequestBadge( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId + authority.invalidate() + workspaceGitSnapshotCompletionAuthorityByDirectory.removeValue(forKey: expectedDirectory) + workspaceGitSnapshotTasksByDirectory.removeValue(forKey: expectedDirectory) + workspaceGitSnapshotTaskContextByDirectory.removeValue(forKey: expectedDirectory) + workspaceGitSnapshotTaskIDByDirectory.removeValue(forKey: expectedDirectory) + workspaceGitSupersededSnapshotTaskIDs.remove(taskID) + let pendingContext = workspaceGitSnapshotPendingContextByDirectory.removeValue( + forKey: expectedDirectory ) - var didApplyMaterialSidebarGitChange = false - let nextBranch = snapshot.branch - if let nextBranch { - if let headSignature = snapshot.headSignature { - if let previousHeadSignature = workspaceGitHeadSignatureByKey[probeKey], - previousHeadSignature != headSignature { - workspaceGitCleanIndexSignatureByKey.removeValue(forKey: probeKey) - workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: probeKey) - } - workspaceGitHeadSignatureByKey[probeKey] = headSignature - } else { - workspaceGitHeadSignatureByKey.removeValue(forKey: probeKey) - } - var isDirty = snapshot.isDirty - if !isDirty, - let indexSignature = snapshot.indexSignature, - let cleanIndexSignature = workspaceGitCleanIndexSignatureByKey[probeKey], - cleanIndexSignature != indexSignature { - if let indexContentSignature = snapshot.indexContentSignature, - let cleanIndexContentSignature = workspaceGitCleanIndexContentSignatureByKey[probeKey], - cleanIndexContentSignature == indexContentSignature { - workspaceGitCleanIndexSignatureByKey[probeKey] = indexSignature - } else { - isDirty = true - } - } - let nextBranchState = SidebarPanelGitBranch(branch: nextBranch, isDirty: isDirty) - didApplyMaterialSidebarGitChange = previousBranchState != nextBranchState - host.updatePanelGitBranch( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId, - branch: nextBranch, - isDirty: isDirty - ) - if !isDirty { - if let indexSignature = snapshot.indexSignature { - workspaceGitCleanIndexSignatureByKey[probeKey] = indexSignature - } else { - workspaceGitCleanIndexSignatureByKey.removeValue(forKey: probeKey) - } - if let indexContentSignature = snapshot.indexContentSignature { - workspaceGitCleanIndexContentSignatureByKey[probeKey] = indexContentSignature - } else { - workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: probeKey) - } - } - } else { - workspaceGitCleanIndexSignatureByKey.removeValue(forKey: probeKey) - workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: probeKey) - workspaceGitHeadSignatureByKey.removeValue(forKey: probeKey) - didApplyMaterialSidebarGitChange = previousBranchState != nil - host.clearPanelGitBranch(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) - } + let requests = workspaceGitSnapshotRequestsByDirectory.removeValue( + forKey: expectedDirectory + ) ?? [:] - switch snapshot.pullRequest { - case .resolved(let pullRequest): - if shouldTrackPullRequests { - let nextBadge = SidebarPullRequestBadge( - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - branch: pullRequest.branch, - isStale: false - ) - didApplyMaterialSidebarGitChange = didApplyMaterialSidebarGitChange - || previousPullRequestBadge != nextBadge - host.updatePanelPullRequest( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId, - badge: nextBadge - ) - } else if host.panelPullRequestBadge( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId - ) != nil { - didApplyMaterialSidebarGitChange = true - host.clearPanelPullRequest(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) - } - case .notFound: - if host.panelPullRequestBadge( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId - ) != nil { - didApplyMaterialSidebarGitChange = true - host.clearPanelPullRequest(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) - } - case .deferred, .unsupportedRepository, .transientFailure: - if !shouldTrackPullRequests, - host.panelPullRequestBadge( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId - ) != nil { - didApplyMaterialSidebarGitChange = true - host.clearPanelPullRequest(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) + for request in requests.values { + workspaceGitSnapshotDirectoryByProbeKey.removeValue(forKey: request.probeKey) + guard case .inFlight = workspaceGitProbeStateByKey[request.probeKey] else { + continue } - } - let isPullRequestRefreshTracked = pullRequestProbing - .workspacePullRequestTrackedPanelIds(workspaceId: probeKey.workspaceId) - .contains(probeKey.panelId) - if let nextBranch, - shouldTrackPullRequests, - previousBranchState?.branch != nextBranch || !isPullRequestRefreshTracked { - pullRequestProbing.scheduleWorkspacePullRequestRefresh( - workspaceId: probeKey.workspaceId, - panelId: probeKey.panelId, - reason: "localGitProbe" + workspaceGitProbeStateByKey[request.probeKey] = .idle + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: request.probeKey.workspaceId, + panelId: request.probeKey.panelId, + reason: "supersededSnapshot", + snapshotRequest: pendingContext?.snapshotRequest ) } - -#if DEBUG - let branchLabel = snapshot.branch ?? "none" - let prLabel: String = { - switch snapshot.pullRequest { - case .deferred: - return "deferred" - case .unsupportedRepository: - return "unsupported" - case .notFound: - return "none" - case .transientFailure: - return "transientFailure" - case .resolved(let pullRequest): - return "#\(pullRequest.number):\(pullRequest.status.rawValue)" - } - }() - debugLog( - "workspace.gitProbe.apply workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + - "panel=\(probeKey.panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + - "pr=\(prLabel)" - ) -#endif } - private func shouldStopWorkspaceGitMetadataRefresh( - _ snapshot: InitialWorkspaceGitMetadataSnapshot - ) -> Bool { - if snapshot.isRepository { - return false - } - switch snapshot.pullRequest { - case .deferred, .transientFailure: - return false - case .unsupportedRepository, .notFound, .resolved: - return true - } + func cancelWorkspaceGitProbeTask(for key: WorkspaceGitProbeKey) { + workspaceGitProbeTasksByKey.removeValue(forKey: key)?.cancel() } + } diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+ProbeApply.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+ProbeApply.swift new file mode 100644 index 000000000000..03ba5202e3ea --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+ProbeApply.swift @@ -0,0 +1,274 @@ +internal import Foundation +internal import CmuxGit + +// MARK: - Probe snapshot application + +extension SidebarGitMetadataService { + func applyWorkspaceGitMetadataSnapshot( + _ snapshot: InitialWorkspaceGitMetadataSnapshot, + probeKey: WorkspaceGitProbeKey, + expectedDirectory: String, + isLastAttempt: Bool + ) { + let wasInFlight: Bool = { + if case .inFlight = workspaceGitProbeStateByKey[probeKey] { return true } + return false + }() + guard host?.mobileHostHasRecentActivity(within: mobileHostDeferral.quietInterval) != true else { + workspaceGitProbeStateByKey[probeKey] = .idle + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + reason: "mobileHostDeferred", + delays: [max( + mobileHostDeferral.deferralInterval, + host?.mobileHostQuietDelay(for: mobileHostDeferral.quietInterval) ?? 0 + )] + ) + return + } + let shouldTrackPullRequests = sidebarPullRequestPollingEnabled + let resolvedPullRequest: SidebarPullRequestBadge? = { + guard shouldTrackPullRequests else { return nil } + guard case .resolved(let pullRequest) = snapshot.pullRequest else { return nil } + return pullRequest + }() + let shouldTrackGitDirectory = snapshot.isRepository || resolvedPullRequest != nil + let shouldFinishProbe = shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt + let shouldStopTrackingGitDirectory = shouldFinishProbe && !shouldTrackGitDirectory + var didClearProbe = false + defer { + if wasInFlight, !didClearProbe { + let rerunPending = workspaceGitProbeRerunPending(for: probeKey) + if rerunPending { + workspaceGitProbeStateByKey[probeKey] = .idle + if shouldFinishProbe { + cancelWorkspaceGitProbeTask(for: probeKey) + } + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + reason: "rerunPending" + ) + } else if shouldStopTrackingGitDirectory { + clearWorkspaceGitProbe(probeKey) + } else if shouldFinishProbe { + finishWorkspaceGitProbeAttempt(probeKey) + } else { + workspaceGitProbeStateByKey[probeKey] = .idle + } + } + } + + guard wasInFlight else { return } + guard let host, host.workspaceExists(probeKey.workspaceId) else { + clearWorkspaceGitProbe(probeKey) + didClearProbe = true + return + } + guard host.panelExists(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) else { + clearWorkspaceGitProbe(probeKey) + didClearProbe = true + return + } + if host.shouldSkipLocalGitMetadata(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) { + clearWorkspaceGitProbeTracking(for: probeKey) + didClearProbe = true + return + } + + guard let currentDirectory = host.gitProbeDirectory( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId + ) else { + clearWorkspaceGitProbe(probeKey) + didClearProbe = true + return + } + if currentDirectory != expectedDirectory { + Self.recordStaleApply() + clearWorkspaceGitProbe(probeKey) + didClearProbe = true +#if DEBUG + debugLog( + "workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + + "panel=\(probeKey.panelId.uuidString.prefix(5)) reason=directoryChanged " + + "expected=\(expectedDirectory) current=\(currentDirectory)" + ) +#endif + return + } + + host.updatePanelDirectory( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + directory: expectedDirectory, + displayLabel: nil + ) + + if shouldTrackGitDirectory { + workspaceGitTrackedDirectoryByKey[probeKey] = expectedDirectory + updateWorkspaceGitMetadataWatcher(for: probeKey, directory: expectedDirectory) + } else { + workspaceGitTrackedDirectoryByKey.removeValue(forKey: probeKey) + stopWorkspaceGitMetadataWatcher(for: probeKey) + } + updateWorkspaceGitMetadataFallbackTimer() + + let previousBranchState = host.panelGitBranch( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId + ) + let previousPullRequestBadge = host.panelPullRequestBadge( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId + ) + var didApplyMaterialSidebarGitChange = false + let nextBranch = snapshot.branch + if let nextBranch { + if let headSignature = snapshot.headSignature { + if let previousHeadSignature = workspaceGitHeadSignatureByKey[probeKey], + previousHeadSignature != headSignature { + workspaceGitCleanIndexSignatureByKey.removeValue(forKey: probeKey) + workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: probeKey) + } + workspaceGitHeadSignatureByKey[probeKey] = headSignature + } else { + workspaceGitHeadSignatureByKey.removeValue(forKey: probeKey) + } + var isDirty = snapshot.isDirty + if !isDirty, + let indexSignature = snapshot.indexSignature, + let cleanIndexSignature = workspaceGitCleanIndexSignatureByKey[probeKey], + cleanIndexSignature != indexSignature { + if let indexContentSignature = snapshot.indexContentSignature, + let cleanIndexContentSignature = workspaceGitCleanIndexContentSignatureByKey[probeKey], + cleanIndexContentSignature == indexContentSignature { + workspaceGitCleanIndexSignatureByKey[probeKey] = indexSignature + } else { + isDirty = true + } + } + let nextBranchState = SidebarPanelGitBranch(branch: nextBranch, isDirty: isDirty) + didApplyMaterialSidebarGitChange = previousBranchState != nextBranchState + host.updatePanelGitBranch( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + branch: nextBranch, + isDirty: isDirty + ) + if !isDirty { + if let indexSignature = snapshot.indexSignature { + workspaceGitCleanIndexSignatureByKey[probeKey] = indexSignature + } else { + workspaceGitCleanIndexSignatureByKey.removeValue(forKey: probeKey) + } + if let indexContentSignature = snapshot.indexContentSignature { + workspaceGitCleanIndexContentSignatureByKey[probeKey] = indexContentSignature + } else { + workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: probeKey) + } + } + } else { + workspaceGitCleanIndexSignatureByKey.removeValue(forKey: probeKey) + workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: probeKey) + workspaceGitHeadSignatureByKey.removeValue(forKey: probeKey) + didApplyMaterialSidebarGitChange = previousBranchState != nil + host.clearPanelGitBranch(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) + } + + switch snapshot.pullRequest { + case .resolved(let pullRequest): + if shouldTrackPullRequests { + let nextBadge = SidebarPullRequestBadge( + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status, + branch: pullRequest.branch, + isStale: false + ) + didApplyMaterialSidebarGitChange = didApplyMaterialSidebarGitChange + || previousPullRequestBadge != nextBadge + host.updatePanelPullRequest( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + badge: nextBadge + ) + } else if host.panelPullRequestBadge( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId + ) != nil { + didApplyMaterialSidebarGitChange = true + host.clearPanelPullRequest(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) + } + case .notFound: + if host.panelPullRequestBadge( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId + ) != nil { + didApplyMaterialSidebarGitChange = true + host.clearPanelPullRequest(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) + } + case .deferred, .unsupportedRepository, .transientFailure: + if !shouldTrackPullRequests, + host.panelPullRequestBadge( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId + ) != nil { + didApplyMaterialSidebarGitChange = true + host.clearPanelPullRequest(workspaceId: probeKey.workspaceId, panelId: probeKey.panelId) + } + } + if let nextBranch, + shouldTrackPullRequests { + pullRequestProbing.seedWorkspacePullRequestRefreshIfNeeded( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + directory: expectedDirectory, + branch: nextBranch, + reason: "localGitProbe" + ) + } + if didApplyMaterialSidebarGitChange { + Self.recordMaterialChange() + } + +#if DEBUG + let branchLabel = snapshot.branch ?? "none" + let prLabel: String = { + switch snapshot.pullRequest { + case .deferred: + return "deferred" + case .unsupportedRepository: + return "unsupported" + case .notFound: + return "none" + case .transientFailure: + return "transientFailure" + case .resolved(let pullRequest): + return "#\(pullRequest.number):\(pullRequest.status.rawValue)" + } + }() + debugLog( + "workspace.gitProbe.apply workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + + "panel=\(probeKey.panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " + + "pr=\(prLabel)" + ) +#endif + } + + private func shouldStopWorkspaceGitMetadataRefresh( + _ snapshot: InitialWorkspaceGitMetadataSnapshot + ) -> Bool { + if snapshot.isRepository { + return false + } + switch snapshot.pullRequest { + case .deferred, .transientFailure: + return false + case .unsupportedRepository, .notFound, .resolved: + return true + } + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SnapshotTasks.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SnapshotTasks.swift index 8b81f402b5b7..e97158890e95 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SnapshotTasks.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SnapshotTasks.swift @@ -11,8 +11,13 @@ extension SidebarGitMetadataService { } requests.removeValue(forKey: key) if requests.isEmpty { + workspaceGitSnapshotCompletionAuthorityByDirectory.removeValue(forKey: directory)?.invalidate() workspaceGitSnapshotRequestsByDirectory.removeValue(forKey: directory) workspaceGitSnapshotTaskContextByDirectory.removeValue(forKey: directory) + workspaceGitSnapshotPendingContextByDirectory.removeValue(forKey: directory) + if let taskID = workspaceGitSnapshotTaskIDByDirectory.removeValue(forKey: directory) { + workspaceGitSupersededSnapshotTaskIDs.remove(taskID) + } workspaceGitSnapshotTasksByDirectory.removeValue(forKey: directory)?.cancel() } else { workspaceGitSnapshotRequestsByDirectory[directory] = requests @@ -20,39 +25,42 @@ extension SidebarGitMetadataService { } func cancelAllWorkspaceGitSnapshotTasks() { + workspaceGitSnapshotApplyBatcher.cancel() + for authority in workspaceGitSnapshotCompletionAuthorityByDirectory.values { + authority.invalidate() + } + workspaceGitSnapshotCompletionAuthorityByDirectory.removeAll() for task in workspaceGitSnapshotTasksByDirectory.values { task.cancel() } workspaceGitSnapshotTasksByDirectory.removeAll() workspaceGitSnapshotTaskContextByDirectory.removeAll() + workspaceGitSnapshotPendingContextByDirectory.removeAll() + workspaceGitSupersededSnapshotTaskIDs.removeAll() + workspaceGitSnapshotTaskIDByDirectory.removeAll() workspaceGitSnapshotRequestsByDirectory.removeAll() workspaceGitSnapshotDirectoryByProbeKey.removeAll() } - func trackedPathEventGenerationForSnapshot( + func snapshotRequestForSnapshot( directory: String, - reason: String - ) -> GitTrackedPathEventGeneration? { - guard shouldUseTrackedSnapshotCache(reason: reason) else { - advanceWorkspaceGitSnapshotCacheGenerationIfEligible(directory: directory) - return nil + reason: String, + fallbackRequest: GitTrackedChangesSnapshotRequest? + ) -> GitTrackedChangesSnapshotRequest? { + if let fallbackRequest { + return fallbackRequest } - guard let generation = workspaceGitSnapshotCacheGeneration(directory: directory) else { + guard reason == "filesystemEvent" else { + advanceWorkspaceGitSnapshotCacheGenerationIfEligible(directory: directory) return nil } - return GitTrackedPathEventGeneration( - namespace: workspaceGitSnapshotCacheNamespace, - generation: generation - ) - } - - private func shouldUseTrackedSnapshotCache(reason: String) -> Bool { - switch reason { - case "filesystemEvent": - return true - default: - return false + let eventID = workspaceGitSnapshotCacheGeneration(directory: directory).map { + GitTrackedPathEventGeneration( + namespace: workspaceGitSnapshotCacheNamespace, + generation: $0 + ) } + return .watcherEvent(nil, eventID: eventID) } func markWorkspaceGitSnapshotRerunPending(directory: String) { @@ -63,4 +71,18 @@ extension SidebarGitMetadataService { markWorkspaceGitProbeRerunPending(for: request.probeKey) } } + + func supersedeWorkspaceGitSnapshotTask( + directory: String, + with context: WorkspaceGitSnapshotTaskContext + ) { + guard workspaceGitSnapshotTaskContextByDirectory[directory] != context, + let taskID = workspaceGitSnapshotTaskIDByDirectory[directory] else { + return + } + workspaceGitSnapshotPendingContextByDirectory[directory] = context + workspaceGitSupersededSnapshotTaskIDs.insert(taskID) + workspaceGitSnapshotCompletionAuthorityByDirectory[directory]?.invalidate() + markWorkspaceGitSnapshotRerunPending(directory: directory) + } } diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SurfaceEvents.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SurfaceEvents.swift index a49f4c5bb37a..b83d4ac4970a 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SurfaceEvents.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+SurfaceEvents.swift @@ -109,8 +109,8 @@ extension SidebarGitMetadataService { let current = host.panelGitBranch(workspaceId: workspaceId, panelId: panelId) let normalizedBranch = GitMetadataService.normalizedBranchName(branch) ?? branch let nextIsDirty = isDirty ?? (current?.branch == normalizedBranch ? current?.isDirty ?? false : false) - let branchChanged = current?.branch != normalizedBranch || current?.isDirty != nextIsDirty - if branchChanged { + let projectionChanged = current?.branch != normalizedBranch || current?.isDirty != nextIsDirty + if projectionChanged { host.updatePanelGitBranch( workspaceId: workspaceId, panelId: panelId, @@ -125,15 +125,18 @@ extension SidebarGitMetadataService { pullRequestProbing.clearWorkspacePullRequestTracking(workspaceId: workspaceId, panelId: panelId) return } - guard branchChanged else { return } - if let directory = host.gitProbeDirectory(workspaceId: workspaceId, panelId: panelId) { + guard projectionChanged else { return } + let directory = host.gitProbeDirectory(workspaceId: workspaceId, panelId: panelId) + if let directory { workspaceGitTrackedDirectoryByKey[probeKey] = directory updateWorkspaceGitMetadataWatcher(for: probeKey, directory: directory) updateWorkspaceGitMetadataFallbackTimer() } - pullRequestProbing.scheduleWorkspacePullRequestRefresh( + pullRequestProbing.seedWorkspacePullRequestRefreshIfNeeded( workspaceId: workspaceId, panelId: panelId, + directory: directory ?? "", + branch: normalizedBranch, reason: "branchChange" ) scheduleWorkspaceGitMetadataRefreshIfPossible( diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift index 1bd99ec6ae6d..7b92891105ad 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService+Watchers.swift @@ -1,4 +1,5 @@ import Foundation +internal import CmuxGit internal import CmuxFoundation // MARK: - Filesystem watchers on each tracked directory's git paths. @@ -35,10 +36,15 @@ extension SidebarGitMetadataService { Task { [weak self] in guard let gitMetadataService = self?.gitMetadataService else { return } - let watchedPaths = await gitMetadataService.watchedPaths(for: directory) + async let watchedPaths = gitMetadataService.watchedPaths(for: directory) + async let repositoryIdentity = gitMetadataService.trackedChangesRepositoryIdentity( + for: directory + ) + let descriptor = await (watchedPaths, repositoryIdentity) await MainActor.run { [weak self] in self?.applyWorkspaceGitMetadataWatcherDescriptor( - watchedPaths, + descriptor.0, + repositoryIdentity: descriptor.1, for: key, request: request ) @@ -48,6 +54,7 @@ extension SidebarGitMetadataService { private func applyWorkspaceGitMetadataWatcherDescriptor( _ watchedPaths: [String]?, + repositoryIdentity: GitTrackedChangesRepositoryIdentity?, for key: WorkspaceGitProbeKey, request: WorkspaceGitMetadataWatcherDescriptorRequest ) { @@ -58,12 +65,14 @@ extension SidebarGitMetadataService { guard sidebarGitMetadataWatchEnabled, workspaceGitTrackedDirectoryByKey[key] == request.directory, - let watchedPaths else { + let watchedPaths, + let repositoryIdentity else { stopWorkspaceGitMetadataWatcher(for: key) return } let watchedPathsKey = WorkspaceGitMetadataWatchedPathsKey(paths: watchedPaths) + workspaceGitSnapshotRepositoryIdentityByDirectory[request.directory] = repositoryIdentity if workspaceGitMetadataWatchersByWatchedPathsKey[watchedPathsKey] != nil { setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: key) moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) @@ -77,16 +86,33 @@ extension SidebarGitMetadataService { moveWorkspaceGitSnapshotCacheEligibility(for: key, to: request.directory) let events = watcher.events workspaceGitMetadataWatcherRefreshTasksByWatchedPathsKey[watchedPathsKey] = Task { @MainActor [weak self] in - for await _ in events { + for await sourceIdentity in events { guard let self else { break } let keys = self.recordWorkspaceGitMetadataFilesystemEvent( forWatchedPathsKey: watchedPathsKey ) + let sourceEvent: GitTrackedPathEventSource = switch sourceIdentity { + case .stable(let eventID): + .stable(GitTrackedPathEventID(rawValue: eventID.rawValue)) + case .mustRescan: + .unknown + case .eventIDsWrapped: + .sequenceReset + } + let requestsByDirectory = await self.watcherEventRequests( + for: keys, + sourceEvent: sourceEvent + ) for key in keys { + let directory = self.workspaceGitMetadataWatcherSourceDirectoryByKey[key] + ?? self.workspaceGitTrackedDirectoryByKey[key] self.scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: key.workspaceId, panelId: key.panelId, - reason: "filesystemEvent" + reason: "filesystemEvent", + snapshotRequest: directory.flatMap { + requestsByDirectory[$0.normalizedGitProbeDirectory] + } ) } } @@ -97,6 +123,52 @@ extension SidebarGitMetadataService { } } + private func watcherEventRequests( + for keys: [WorkspaceGitProbeKey], + sourceEvent: GitTrackedPathEventSource + ) async -> [String: GitTrackedChangesSnapshotRequest] { + let directories = Set(keys.compactMap { key in + (workspaceGitMetadataWatcherSourceDirectoryByKey[key] + ?? workspaceGitTrackedDirectoryByKey[key])?.normalizedGitProbeDirectory + }) + var identityByDirectory: [String: GitTrackedChangesRepositoryIdentity] = [:] + for directory in directories { + let identity: GitTrackedChangesRepositoryIdentity? + if let cachedIdentity = workspaceGitSnapshotRepositoryIdentityByDirectory[directory] { + identity = cachedIdentity + } else { + identity = await gitMetadataService.trackedChangesRepositoryIdentity( + for: directory + ) + } + if let identity { + workspaceGitSnapshotRepositoryIdentityByDirectory[directory] = identity + identityByDirectory[directory] = identity + } + } + + var authorityByIdentity: [ + GitTrackedChangesRepositoryIdentity: GitTrackedChangesSnapshotAuthority + ] = [:] + for identity in Set(identityByDirectory.values) { + authorityByIdentity[identity] = await gitMetadataService.recordTrackedPathEvent( + for: identity, + sourceEvent: sourceEvent + ) + } + + return Dictionary(uniqueKeysWithValues: directories.map { directory in + let authority = identityByDirectory[directory].flatMap { authorityByIdentity[$0] } + let eventID = workspaceGitSnapshotCacheGeneration(directory: directory).map { + GitTrackedPathEventGeneration( + namespace: workspaceGitSnapshotCacheNamespace, + generation: $0 + ) + } + return (directory, .watcherEvent(authority, eventID: eventID)) + }) + } + func workspaceGitSnapshotCacheGeneration(directory: String) -> UInt64? { workspaceGitSnapshotCacheGenerationByDirectory[directory] } @@ -203,6 +275,7 @@ extension SidebarGitMetadataService { guard let directory else { return } if workspaceGitMetadataWatcherKeysBySourceDirectory[directory]?.isEmpty != false { workspaceGitSnapshotCacheGenerationByDirectory.removeValue(forKey: directory) + workspaceGitSnapshotRepositoryIdentityByDirectory.removeValue(forKey: directory) } } @@ -237,5 +310,6 @@ extension SidebarGitMetadataService { workspaceGitMetadataWatcherProbeKeysByWatchedPathsKey.removeAll() workspaceGitMetadataWatcherDescriptorRequestsByKey.removeAll() workspaceGitSnapshotCacheGenerationByDirectory.removeAll() + workspaceGitSnapshotRepositoryIdentityByDirectory.removeAll() } } diff --git a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift index 256d09d7335d..961b9eb9383b 100644 --- a/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift +++ b/Packages/macOS/CmuxSidebarGit/Sources/CmuxSidebarGit/Service/SidebarGitMetadataService.swift @@ -24,6 +24,7 @@ internal import CmuxFoundation /// every probe/watcher state transition. @MainActor public final class SidebarGitMetadataService: SidebarGitMetadataServing { + static let runtimeMetrics = CmuxSidebarGitRuntimeMetrics() // MARK: Tuning constants (legacy TabManager values, preserved exactly) nonisolated static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] @@ -42,12 +43,18 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { // Process-wide cap on concurrent snapshot probes (injected, shared // across windows by the composition root). let probeLimiter: WorkspaceGitMetadataProbeLimiter - // Drives the retry gaps and fallback interval. + // Drives per-window retry gaps. let clock: any GitPollClock + // Owns the single process-wide fallback timer and round broadcast. + let fallbackCoordinator: WorkspaceGitFallbackCoordinator + private let fallbackRegistrationID = UUID() // Mobile-host background-work deferral intervals. let mobileHostDeferral: MobileHostDeferralPolicy // Debug diagnostics sink (the app injects its debug logger in DEBUG). let debugLog: @Sendable (String) -> Void + // Defaults to the process-wide opt-in recorder. Focused tests replace it + // with an isolated enabled instance so causal counts stay deterministic. + var runtimeMetricsRecorder = SidebarGitMetadataService.runtimeMetrics // The window-side seam; set once via attach(host:). Weak: the host owns // this service. private(set) weak var host: (any SidebarGitHosting)? @@ -71,11 +78,22 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { var workspaceGitMetadataFilesystemEventGeneration: UInt64 = 0 let workspaceGitSnapshotCacheNamespace = UUID() var workspaceGitSnapshotCacheGenerationByDirectory: [String: UInt64] = [:] + var workspaceGitSnapshotRepositoryIdentityByDirectory: [ + String: GitTrackedChangesRepositoryIdentity + ] = [:] var workspaceGitSnapshotRequestsByDirectory: [String: [WorkspaceGitProbeKey: WorkspaceGitSnapshotProbeRequest]] = [:] var workspaceGitSnapshotTasksByDirectory: [String: Task] = [:] var workspaceGitSnapshotTaskContextByDirectory: [String: WorkspaceGitSnapshotTaskContext] = [:] + var workspaceGitSnapshotPendingContextByDirectory: [String: WorkspaceGitSnapshotTaskContext] = [:] + var workspaceGitSupersededSnapshotTaskIDs: Set = [] + var workspaceGitSnapshotTaskIDByDirectory: [String: UUID] = [:] + var workspaceGitSnapshotCompletionAuthorityByDirectory: [String: DetachedCompletionAuthority] = [:] + var workspaceGitSnapshotCompletionGeneration: UInt64 = 0 var workspaceGitSnapshotDirectoryByProbeKey: [WorkspaceGitProbeKey: String] = [:] - var workspaceGitMetadataFallbackTask: Task? + let workspaceGitSnapshotApplyBatcher = LatestWinsBatcher( + quietDelay: 0.016, + maximumDelay: 0.05 + ) private var lastSidebarGitMetadataWatchEnabled = false /// Creates the metadata service. @@ -95,6 +113,7 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { pullRequestProbing: any PullRequestProbing, probeLimiter: WorkspaceGitMetadataProbeLimiter, clock: any GitPollClock = SystemGitPollClock(), + fallbackCoordinator: WorkspaceGitFallbackCoordinator? = nil, mobileHostDeferral: MobileHostDeferralPolicy = .standard, debugLog: @escaping @Sendable (String) -> Void = { _ in } ) { @@ -103,18 +122,30 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { self.pullRequestProbing = pullRequestProbing self.probeLimiter = probeLimiter self.clock = clock + self.fallbackCoordinator = fallbackCoordinator ?? WorkspaceGitFallbackCoordinator( + clock: clock, + interval: Self.workspaceGitMetadataFallbackRefreshInterval + ) self.mobileHostDeferral = mobileHostDeferral self.debugLog = debugLog + self.fallbackCoordinator.register( + self, + registrationID: fallbackRegistrationID + ) } deinit { - workspaceGitMetadataFallbackTask?.cancel() for task in workspaceGitProbeTasksByKey.values { task.cancel() } for task in workspaceGitSnapshotTasksByDirectory.values { task.cancel() } + let fallbackCoordinator = fallbackCoordinator + let fallbackRegistrationID = fallbackRegistrationID + Task { @MainActor in + fallbackCoordinator.unregister(fallbackRegistrationID) + } } /// Wires the host and captures the initial watch-setting value (matching @@ -134,38 +165,87 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { host?.isPullRequestPollingEnabled ?? false } - // MARK: Fallback timer + // MARK: Process-wide fallback rounds + + var requiresWorkspaceGitFallback: Bool { + sidebarGitMetadataWatchEnabled && !workspaceGitTrackedDirectoryByKey.isEmpty + } func updateWorkspaceGitMetadataFallbackTimer() { - guard sidebarGitMetadataWatchEnabled, - !workspaceGitTrackedDirectoryByKey.isEmpty else { - workspaceGitMetadataFallbackTask?.cancel() - workspaceGitMetadataFallbackTask = nil + fallbackCoordinator.serviceStateDidChange() + } + + public func refreshTrackedWorkspaceGitMetadata(reason: String) { + if reason == "fallbackTimer" { + fallbackCoordinator.fireFallbackRound() return } + refreshTrackedWorkspaceGitMetadata(reason: reason, snapshotRequest: nil) + } - guard workspaceGitMetadataFallbackTask == nil else { - return + func receiveWorkspaceGitFallbackRound(_ round: GitFallbackRoundID) { + Task { @MainActor [weak self] in + await self?.refreshTrackedWorkspaceGitMetadata(fallbackRound: round) } + } + + private func refreshTrackedWorkspaceGitMetadata( + fallbackRound: GitFallbackRoundID + ) async { + guard let host else { return } + let activeProbeKeys = activeWorkspaceGitProbeKeys + var requestByDirectory: [String: GitTrackedChangesSnapshotRequest] = [:] - let clock = clock - let interval = Self.workspaceGitMetadataFallbackRefreshInterval - workspaceGitMetadataFallbackTask = Task { @MainActor [weak self] in - while !Task.isCancelled { - // Bounded, cancellable fallback interval on the injected clock - // (replaces the repeating DispatchSource timer). - do { - try await clock.sleep(for: .seconds(interval)) - } catch { - return + for workspaceId in host.orderedWorkspaceIds() { + for panelId in trackedWorkspaceGitMetadataPollCandidatePanelIds( + workspaceId: workspaceId, + activeProbeKeys: activeProbeKeys + ) { + guard let directory = host.gitProbeDirectory( + workspaceId: workspaceId, + panelId: panelId + ) else { continue } + let normalizedDirectory = directory.normalizedGitProbeDirectory + let request: GitTrackedChangesSnapshotRequest + if let cached = requestByDirectory[normalizedDirectory] { + request = cached + } else { + let identity: GitTrackedChangesRepositoryIdentity? + if let cachedIdentity = workspaceGitSnapshotRepositoryIdentityByDirectory[ + normalizedDirectory + ] { + identity = cachedIdentity + } else { + identity = await gitMetadataService.trackedChangesRepositoryIdentity( + for: normalizedDirectory + ) + } + if let identity { + workspaceGitSnapshotRepositoryIdentityByDirectory[normalizedDirectory] = identity + let authority = await gitMetadataService.trackedChangesSnapshotAuthority( + for: identity, + fallbackRoundID: fallbackRound + ) + request = .fallbackRound(id: fallbackRound, authority: authority) + } else { + request = .fallbackRound(id: fallbackRound, authority: nil) + } + requestByDirectory[normalizedDirectory] = request } - guard let self, !Task.isCancelled else { return } - self.refreshTrackedWorkspaceGitMetadata(reason: "fallbackTimer") + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "fallbackTimer", + snapshotRequest: request + ) } } } - public func refreshTrackedWorkspaceGitMetadata(reason: String) { + private func refreshTrackedWorkspaceGitMetadata( + reason: String, + snapshotRequest: GitTrackedChangesSnapshotRequest? + ) { guard let host else { return } let activeProbeKeys = activeWorkspaceGitProbeKeys @@ -177,7 +257,8 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, panelId: panelId, - reason: reason + reason: reason, + snapshotRequest: snapshotRequest ) } } @@ -194,8 +275,6 @@ public final class SidebarGitMetadataService: SidebarGitMetadataServing { guard isEnabled else { stopAllWorkspaceGitMetadataWatchers() - workspaceGitMetadataFallbackTask?.cancel() - workspaceGitMetadataFallbackTask = nil workspaceGitProbeStateByKey.removeAll() for task in workspaceGitProbeTasksByKey.values { task.cancel() diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/CmuxSidebarGitRuntimeMetricsTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/CmuxSidebarGitRuntimeMetricsTests.swift new file mode 100644 index 000000000000..0fa9da78eaaf --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/CmuxSidebarGitRuntimeMetricsTests.swift @@ -0,0 +1,165 @@ +import Testing +@testable import CmuxSidebarGit + +@Suite(.serialized) +struct CmuxSidebarGitRuntimeMetricsTests { + @Test func recordingDefaultsDisabled() { + let metrics = CmuxSidebarGitRuntimeMetricsRecorder() + + metrics.recordSnapshotBatchApply() + metrics.recordMaterialChange() + metrics.recordPullRequestSeed() + metrics.recordPullRequestTraversal() + metrics.recordStaleApply() + metrics.recordPullRequestRefreshRequest() + metrics.recordPullRequestTaskStarted() + metrics.recordPullRequestTaskJoined() + metrics.recordPullRequestRepoFetch() + metrics.recordPullRequestStaleCompletionRejectedOffMain() + metrics.recordPullRequestMainActorApplyEntered() + metrics.recordPullRequestFollowUpStarted() + metrics.recordGitStaleCompletionRejectedOffMain() + metrics.recordGitMainActorApplyEntered() + + let snapshot = metrics.snapshot() + #expect(snapshot.schemaVersion == 2) + #expect(!snapshot.enabled) + #expect(snapshot.snapshotBatchApplyCount == 0) + #expect(snapshot.materialChangeCount == 0) + #expect(snapshot.pullRequestSeedCount == 0) + #expect(snapshot.pullRequestTraversalCount == 0) + #expect(snapshot.staleApplyCount == 0) + #expect(snapshot.pullRequestRefreshRequestCount == 0) + #expect(snapshot.pullRequestTaskStartedCount == 0) + #expect(snapshot.pullRequestTaskJoinedCount == 0) + #expect(snapshot.pullRequestRepoFetchCount == 0) + #expect(snapshot.pullRequestStaleCompletionRejectedOffMainCount == 0) + #expect(snapshot.pullRequestMainActorApplyEnteredCount == 0) + #expect(snapshot.pullRequestFollowUpStartedCount == 0) + #expect(snapshot.gitStaleCompletionRejectedOffMainCount == 0) + #expect(snapshot.gitMainActorApplyEnteredCount == 0) + } + + @Test func enabledResetAndAtomicTake() { + let metrics = CmuxSidebarGitRuntimeMetricsRecorder() + metrics.reset(enable: true) + + metrics.recordSnapshotBatchApply() + metrics.recordMaterialChange() + metrics.recordPullRequestSeed() + metrics.recordPullRequestTraversal() + metrics.recordStaleApply() + metrics.recordPullRequestRefreshRequest() + metrics.recordPullRequestTaskStarted() + metrics.recordPullRequestTaskJoined() + metrics.recordPullRequestRepoFetch() + metrics.recordPullRequestStaleCompletionRejectedOffMain() + metrics.recordPullRequestMainActorApplyEntered() + metrics.recordPullRequestFollowUpStarted() + metrics.recordGitStaleCompletionRejectedOffMain() + metrics.recordGitMainActorApplyEntered() + + let snapshot = metrics.snapshot() + #expect(snapshot.enabled) + #expect(snapshot.snapshotBatchApplyCount == 1) + #expect(snapshot.materialChangeCount == 1) + #expect(snapshot.pullRequestSeedCount == 1) + #expect(snapshot.pullRequestTraversalCount == 1) + #expect(snapshot.staleApplyCount == 1) + #expect(snapshot.pullRequestRefreshRequestCount == 1) + #expect(snapshot.pullRequestTaskStartedCount == 1) + #expect(snapshot.pullRequestTaskJoinedCount == 1) + #expect(snapshot.pullRequestRepoFetchCount == 1) + #expect(snapshot.pullRequestStaleCompletionRejectedOffMainCount == 1) + #expect(snapshot.pullRequestMainActorApplyEnteredCount == 1) + #expect(snapshot.pullRequestFollowUpStartedCount == 1) + #expect(snapshot.gitStaleCompletionRejectedOffMainCount == 1) + #expect(snapshot.gitMainActorApplyEnteredCount == 1) + #expect(metrics.snapshotAndReset() == snapshot) + #expect(metrics.snapshot() == .zero(enabled: true)) + + metrics.recordSnapshotBatchApply() + metrics.reset(enable: true) + #expect(metrics.snapshot() == .zero(enabled: true)) + } + + @Test func disablePreservesSnapshotAndStopsRecording() { + let metrics = CmuxSidebarGitRuntimeMetricsRecorder() + metrics.reset(enable: true) + metrics.recordSnapshotBatchApply() + + metrics.disable() + metrics.recordSnapshotBatchApply() + + let snapshot = metrics.snapshot() + #expect(!snapshot.enabled) + #expect(snapshot.snapshotBatchApplyCount == 1) + } + + @Test func concurrentRecordingDoesNotLoseUpdates() async { + let metrics = CmuxSidebarGitRuntimeMetricsRecorder() + metrics.reset(enable: true) + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<100 { + group.addTask { + for _ in 0..<100 { + metrics.recordSnapshotBatchApply() + metrics.recordMaterialChange() + metrics.recordPullRequestSeed() + metrics.recordPullRequestTraversal() + metrics.recordStaleApply() + metrics.recordPullRequestRefreshRequest() + metrics.recordPullRequestTaskStarted() + metrics.recordPullRequestTaskJoined() + metrics.recordPullRequestRepoFetch() + metrics.recordPullRequestStaleCompletionRejectedOffMain() + metrics.recordPullRequestMainActorApplyEntered() + metrics.recordPullRequestFollowUpStarted() + metrics.recordGitStaleCompletionRejectedOffMain() + metrics.recordGitMainActorApplyEntered() + } + } + } + } + + let snapshot = metrics.snapshot() + #expect(snapshot.snapshotBatchApplyCount == 10_000) + #expect(snapshot.materialChangeCount == 10_000) + #expect(snapshot.pullRequestSeedCount == 10_000) + #expect(snapshot.pullRequestTraversalCount == 10_000) + #expect(snapshot.staleApplyCount == 10_000) + #expect(snapshot.pullRequestRefreshRequestCount == 10_000) + #expect(snapshot.pullRequestTaskStartedCount == 10_000) + #expect(snapshot.pullRequestTaskJoinedCount == 10_000) + #expect(snapshot.pullRequestRepoFetchCount == 10_000) + #expect(snapshot.pullRequestStaleCompletionRejectedOffMainCount == 10_000) + #expect(snapshot.pullRequestMainActorApplyEnteredCount == 10_000) + #expect(snapshot.pullRequestFollowUpStartedCount == 10_000) + #expect(snapshot.gitStaleCompletionRejectedOffMainCount == 10_000) + #expect(snapshot.gitMainActorApplyEnteredCount == 10_000) + } +} + +private extension CmuxSidebarGitRuntimeMetricsSnapshot { + static func zero(enabled: Bool) -> Self { + CmuxSidebarGitRuntimeMetricsSnapshot( + schemaVersion: 2, + enabled: enabled, + snapshotBatchApplyCount: 0, + materialChangeCount: 0, + pullRequestSeedCount: 0, + pullRequestTraversalCount: 0, + staleApplyCount: 0, + pullRequestRefreshRequestCount: 0, + pullRequestTaskStartedCount: 0, + pullRequestTaskJoinedCount: 0, + pullRequestRepoFetchCount: 0, + pullRequestStaleCompletionRejectedOffMainCount: 0, + pullRequestMainActorApplyEnteredCount: 0, + pullRequestFollowUpStartedCount: 0, + gitStaleCompletionRejectedOffMainCount: 0, + gitMainActorApplyEnteredCount: 0 + ) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeApplyRaceTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeApplyRaceTests.swift index d3141c2ba9ac..baa9a6a822bf 100644 --- a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeApplyRaceTests.swift +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeApplyRaceTests.swift @@ -22,10 +22,15 @@ import CmuxGit return service } - private func waitUntil(maxYields: Int = 5_000, _ predicate: () -> Bool) async -> Bool { - for _ in 0.. Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { if predicate() { return true } - await Task.yield() + try? await clock.sleep(for: .milliseconds(1)) } return predicate() } diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests+PullRequestPolling.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests+PullRequestPolling.swift new file mode 100644 index 000000000000..f814bff51889 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests+PullRequestPolling.swift @@ -0,0 +1,363 @@ +import Foundation +import Testing +import CmuxGit +@testable import CmuxSidebarGit + +extension ProbeSchedulingTests { + /// A snapshot batch can project several panels in one main-actor turn. + /// Its PR seeds should produce one host traversal containing every panel, + /// rather than one traversal for every apply callback. + @Test(.timeLimit(.minutes(1))) + func sameTurnFeatureSnapshotSeedsCoalesceToOnePullRequestTraversal() async throws { + let directory = "/tmp/shared-repo" + let host = RecordingSidebarGitHost() + host.pollingEnabled = true + let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) + let secondPanelId = UUID() + host.workspaces[0].state.panels[secondPanelId] = RecordingSidebarGitHost.PanelState( + directory: directory + ) + let clock = ManualGitPollClock() + let reader = GatedMetadataReader( + metadata: .repository(branch: "feature/batched"), + gated: true + ) + let pullRequestService = makePullRequestService(host: host) + let service = makeService( + host: host, + reader: reader, + clock: clock, + pullRequestProbing: pullRequestService + ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() + + for panelId in [firstPanelId, secondPanelId] { + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + } + for _ in 0..<2 { + await clock.waitForSleeper() + await clock.resumeNext() + } + await reader.openGate() + var projectedPanelIds: Set = [] + while projectedPanelIds.count < 2, let event = await projectionEvents.next() { + if case .gitBranch(workspaceId, let panelId, "feature/batched", false) = event { + projectedPanelIds.insert(panelId) + } + } + #expect(projectedPanelIds == [firstPanelId, secondPanelId]) + await host.waitForOrderedWorkspaceIdsReadCount(1) + + #expect(host.orderedWorkspaceIdsReadCount == 1) + #expect(host.panelGitBranchPanelIdsReadCount == 1) + #expect(host.panelPullRequestPanelIdsReadCount == 1) + } + + /// A feature source seeds once. Changing either half of its normalized + /// directory/branch identity seeds once more, while a command hint remains + /// an explicit force request even when that source is unchanged. + @Test(.timeLimit(.minutes(1))) + func featureSourceChangesSeedOnceAndCommandHintStillForces() async throws { + let host = RecordingSidebarGitHost() + host.pollingEnabled = true + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + let gitClock = ManualGitPollClock() + let pullRequestClock = ManualGitPollClock() + let pullRequestService = makePullRequestService(host: host, clock: pullRequestClock) + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "feature/one")), + clock: gitClock, + pullRequestProbing: pullRequestService + ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() + + for expectedApplyCount in 1...2 { + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + await gitClock.waitForSleeper(duration: 0) + #expect(await gitClock.resumeFirst(duration: 0)) + var observedExpectedProjection = false + while let event = await projectionEvents.next() { + if case .gitBranch(workspaceId, panelId, "feature/one", false) = event { + observedExpectedProjection = true + break + } + } + #expect(observedExpectedProjection) + if expectedApplyCount == 1 { + await host.waitForOrderedWorkspaceIdsReadCount(1) + } + } + #expect(host.orderedWorkspaceIdsReadCount == 1) + + service.updateSurfaceDirectory( + workspaceId: workspaceId, + panelId: panelId, + directory: " file:///tmp/repo-two ", + displayLabel: nil + ) + #expect(host.orderedWorkspaceIdsReadCount == 2) + service.updateSurfaceDirectory( + workspaceId: workspaceId, + panelId: panelId, + directory: "/tmp/repo-two", + displayLabel: nil + ) + #expect(host.orderedWorkspaceIdsReadCount == 2) + + service.updateSurfaceGitBranch( + workspaceId: workspaceId, + panelId: panelId, + branch: " feature/two ", + isDirty: false + ) + await host.waitForOrderedWorkspaceIdsReadCount(3) + service.updateSurfaceGitBranch( + workspaceId: workspaceId, + panelId: panelId, + branch: "feature/two", + isDirty: false + ) + #expect(host.orderedWorkspaceIdsReadCount == 3) + + pullRequestService.handleWorkspacePullRequestCommandHint( + workspaceId: workspaceId, + panelId: panelId, + action: "merge", + target: nil + ) + await host.waitForOrderedWorkspaceIdsReadCount(4) + #expect(host.orderedWorkspaceIdsReadCount == 4) + } + + /// A shell branch report can update only the dirty projection while its + /// normalized repository source stays unchanged. That projection update + /// must not force a PR traversal; command hints remain force requests. + @Test(.timeLimit(.minutes(1))) + func sameBranchDirtyOnlyUpdateDoesNotTraversePullRequestPoller() async throws { + let host = RecordingSidebarGitHost() + host.pollingEnabled = true + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + let gitClock = ManualGitPollClock() + let pullRequestService = makePullRequestService(host: host) + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), + clock: gitClock, + pullRequestProbing: pullRequestService + ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() + + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + await gitClock.waitForSleeper(duration: 0) + #expect(await gitClock.resumeFirst(duration: 0)) + var observedInitialProjection = false + while let event = await projectionEvents.next() { + if case .gitBranch(workspaceId, panelId, "feature/x", false) = event { + observedInitialProjection = true + break + } + } + #expect(observedInitialProjection) + await host.waitForOrderedWorkspaceIdsReadCount(1) + + let readsBeforeDirtyUpdate = pullRequestTraversalReads(host) + service.updateSurfaceGitBranch( + workspaceId: workspaceId, + panelId: panelId, + branch: " feature/x ", + isDirty: true + ) + + #expect(host.workspaces[0].state.panels[panelId]?.branch == SidebarPanelGitBranch( + branch: "feature/x", + isDirty: true + )) + #expect(pullRequestTraversalReads(host) == readsBeforeDirtyUpdate) + + let readsBeforeCommandHint = pullRequestTraversalReads(host) + pullRequestService.handleWorkspacePullRequestCommandHint( + workspaceId: workspaceId, + panelId: panelId, + action: "merge", + target: nil + ) + await host.waitForOrderedWorkspaceIdsReadCount(readsBeforeCommandHint[0] + 1) + #expect(pullRequestTraversalReads(host) == readsBeforeCommandHint.map { $0 + 1 }) + } + + /// A filesystem event that arrives while a probe is already in flight is a + /// freshness signal independent of whether the stale snapshot changes + /// visible sidebar state. It should coalesce to one follow-up probe. + @Test(.timeLimit(.minutes(1))) + func inFlightFilesystemEventChainsOneProbeRerun() async throws { + let host = RecordingSidebarGitHost() + host.pollingEnabled = true + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + host.workspaces[0].state.panels[panelId]?.branch = SidebarPanelGitBranch( + branch: "feature/x", + isDirty: true + ) + let clock = ManualGitPollClock() + let reader = GatedMetadataReader( + metadata: .repository(branch: "feature/x", isDirty: true), + gated: true + ) + let service = makeService(host: host, reader: reader, clock: clock) + + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + await clock.waitForSleeper() + await clock.resumeNext() + #expect(await reader.waitForTrackedPathEventGenerationProbe()) + + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + await clock.waitForSleeper() + await clock.resumeNext() + #expect(await waitUntil { service.workspaceGitProbeRerunPending(for: key) }) + #expect(service.workspaceGitProbeRerunPending(for: key)) + await reader.openGate() + + #expect(await clock.waitForRecordedDuration(0, count: 3)) + let immediateProbeSleeps = await clock.recordedDurations.filter { $0 == 0 }.count + + #expect(immediateProbeSleeps == 3) + service.clearWorkspaceGitProbes(workspaceId: workspaceId) + } + + /// With PR polling disabled, a branch probe does not touch the PR seam. + @Test func pollingDisabledSuppressesPullRequestScheduling() async throws { + let host = RecordingSidebarGitHost() + host.pollingEnabled = false + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + let clock = ManualGitPollClock() + let pullRequestProbing = RecordingPullRequestProbing() + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "main")), + clock: clock, + pullRequestProbing: pullRequestProbing + ) + + service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "test" + ) + await clock.waitForSleeper() + await clock.resumeNext() + + #expect(await waitUntil { + host.events.contains { event in + if case .gitBranch = event { return true } + return false + } + }) + #expect(pullRequestProbing.scheduledRefreshes.isEmpty) + } + + /// A probe whose panel directory changes while the snapshot is in flight + /// is dropped: no projection lands for the stale directory. + @Test(.timeLimit(.minutes(1))) + func directoryChangeWhileProbeInFlightDropsTheApply() async throws { + let host = RecordingSidebarGitHost() + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/old") + let clock = ManualGitPollClock() + let reader = GatedMetadataReader( + metadata: .repository(branch: "main"), + gated: true + ) + let service = makeService(host: host, reader: reader, clock: clock) + + service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "test" + ) + await clock.waitForSleeper() + await clock.resumeNext() + + // Wait until the snapshot probe has started reading, then move the + // panel to a different directory before letting the read finish. + #expect(await reader.waitForProbe()) + host.workspaces[0].state.panels[panelId]?.directory = "/tmp/new" + await reader.openGate() + + // The stale apply must clear the probe rather than project "main". + #expect(await waitUntil { + service.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId).isEmpty + }) + #expect(host.workspaces[0].state.panels[panelId]?.branch == nil) + #expect(!host.events.contains { event in + if case .gitBranch = event { return true } + return false + }) + } + + /// Disabling the git watch setting tears the subsystem down: all sidebar + /// git metadata cleared and the PR seam reset. + @Test func disablingWatchSettingClearsMetadataAndResetsPullRequests() async throws { + let host = RecordingSidebarGitHost() + host.addWorkspace(panelDirectory: "/tmp/repo") + let pullRequestProbing = RecordingPullRequestProbing() + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .nonRepository), + clock: ManualGitPollClock(), + pullRequestProbing: pullRequestProbing + ) + + host.watchEnabled = false + service.sidebarGitMetadataWatchSettingsDidChange() + + #expect(host.events.contains(.clearAllGitMetadata)) + #expect(pullRequestProbing.resetCount == 1) + } + + /// Closing a workspace clears its probe state and PR tracking. + @Test func clearWorkspaceGitProbesDropsTrackingForThatWorkspace() async throws { + let host = RecordingSidebarGitHost() + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + let clock = ManualGitPollClock() + let pullRequestProbing = RecordingPullRequestProbing() + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .nonRepository, gated: true), + clock: clock, + pullRequestProbing: pullRequestProbing + ) + + service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "test" + ) + await clock.waitForSleeper() + #expect(!service.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId).isEmpty) + + service.clearWorkspaceGitProbes(workspaceId: workspaceId) + + #expect(service.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId).isEmpty) + #expect(pullRequestProbing.clearedTrackingWorkspaceIds == [workspaceId]) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift index 04870cfb7ab7..c443b1d065b0 100644 --- a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift @@ -5,11 +5,11 @@ import CmuxGit @MainActor @Suite struct ProbeSchedulingTests { - private func makeService( + func makeService( host: RecordingSidebarGitHost, reader: GatedMetadataReader, clock: ManualGitPollClock, - pullRequestProbing: RecordingPullRequestProbing = RecordingPullRequestProbing() + pullRequestProbing: any PullRequestProbing = RecordingPullRequestProbing() ) -> SidebarGitMetadataService { let service = SidebarGitMetadataService( workspaceGitMetadataReader: reader, @@ -22,16 +22,42 @@ import CmuxGit return service } - private func waitUntil(maxYields: Int = 5_000, _ predicate: () -> Bool) async -> Bool { - for _ in 0.. Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { if predicate() { return true } - await Task.yield() + try? await clock.sleep(for: .milliseconds(1)) } return predicate() } + func makePullRequestService( + host: RecordingSidebarGitHost, + clock: ManualGitPollClock = ManualGitPollClock() + ) -> PullRequestPollService { + let service = PullRequestPollService( + gitMetadataService: GitMetadataService(), + probeService: PullRequestProbeService(commandRunner: ForbiddenCommandRunner()), + clock: clock + ) + service.attach(host: host) + return service + } + + func pullRequestTraversalReads(_ host: RecordingSidebarGitHost) -> [Int] { + [ + host.orderedWorkspaceIdsReadCount, + host.panelGitBranchPanelIdsReadCount, + host.panelPullRequestPanelIdsReadCount, + ] + } + /// The initial probe's retry offsets [0, 0.5, 1.5, 3, 6, 10] are absolute /// offsets from scheduling time, walked as sequential clock gaps. The /// reader gate stays closed so no snapshot applies mid-walk (an applied @@ -99,7 +125,6 @@ import CmuxGit clock: clock, pullRequestProbing: pullRequestProbing ) - service.updateSurfaceGitBranch( workspaceId: workspaceId, panelId: panelId, @@ -223,6 +248,7 @@ import CmuxGit clock: clock, pullRequestProbing: pullRequestProbing ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, @@ -232,25 +258,25 @@ import CmuxGit await clock.waitForSleeper() await clock.resumeNext() - #expect(await waitUntil { - host.events.contains { event in - if case .gitBranch(workspaceId, panelId, "feature/x", true) = event { - return true - } - return false + var observedProjection = false + while let event = await projectionEvents.next() { + if case .gitBranch(workspaceId, panelId, "feature/x", true) = event { + observedProjection = true + break } - }) + } + #expect(observedProjection) #expect(host.workspaces[0].state.panels[panelId]?.branch == SidebarPanelGitBranch(branch: "feature/x", isDirty: true)) #expect(pullRequestProbing.scheduledRefreshes.contains { $0.workspaceId == workspaceId && $0.panelId == panelId && $0.reason == "localGitProbe" }) } - /// Reapplying the same branch from a filesystem-triggered git probe keeps - /// the sidebar branch fresh without forcing another PR refresh when that - /// panel is already tracked by the PR poller. + /// Poll tracking restored without a local source identity is insufficient + /// to dedupe the first snapshot. That snapshot seeds ownership exactly + /// once so later directory or branch comparisons are well-defined. @Test(.timeLimit(.minutes(1))) - func knownBranchSnapshotDoesNotForceDuplicatePullRequestRefresh() async throws { + func trackedBranchWithoutSourceIdentitySeedsPullRequestRefreshOnce() async throws { let host = RecordingSidebarGitHost() host.pollingEnabled = true let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") @@ -268,6 +294,7 @@ import CmuxGit clock: clock, pullRequestProbing: pullRequestProbing ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() service.scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, @@ -277,17 +304,20 @@ import CmuxGit await clock.waitForSleeper() await clock.resumeNext() - #expect(await waitUntil { - host.events.contains { event in - if case .gitBranch = event { return true } - return false + var observedProjection = false + while let event = await projectionEvents.next() { + if case .gitBranch = event { + observedProjection = true + break } - }) + } + #expect(observedProjection) #expect(host.workspaces[0].state.panels[panelId]?.branch == SidebarPanelGitBranch( branch: "feature/x", isDirty: true )) - #expect(pullRequestProbing.scheduledRefreshes.isEmpty) + #expect(pullRequestProbing.scheduledRefreshes.count == 1) + #expect(pullRequestProbing.scheduledRefreshes.first?.reason == "localGitProbe") } /// Restored sessions can already have a branch projected before the first @@ -311,6 +341,7 @@ import CmuxGit clock: clock, pullRequestProbing: pullRequestProbing ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, @@ -320,7 +351,9 @@ import CmuxGit await clock.waitForSleeper() await clock.resumeNext() #expect(await reader.waitForTrackedPathEventGenerationProbe()) - #expect(await waitUntil { pullRequestProbing.scheduledRefreshes.count == 1 }) + while let event = await projectionEvents.next() { + if case .gitBranch = event { break } + } #expect(pullRequestProbing.scheduledRefreshes.count == 1) let scheduledRefresh = try #require(pullRequestProbing.scheduledRefreshes.first) @@ -329,170 +362,60 @@ import CmuxGit #expect(scheduledRefresh.reason == "localGitProbe") } - /// A filesystem event that arrives while a probe is already in flight is a - /// freshness signal independent of whether the stale snapshot changes - /// visible sidebar state. It should coalesce to one follow-up probe. - @Test(.timeLimit(.minutes(1))) - func inFlightFilesystemEventChainsOneProbeRerun() async throws { + /// Default branches intentionally own no PR poll deadline. Reapplying an + /// unchanged local snapshot must still remember that source, avoid the + /// global refresh traversal, and clear an old badge only once. + @Test(.timeLimit(.minutes(1)), arguments: [" main ", " master "]) + func repeatedDefaultBranchSnapshotSuppressesPullRequestReseedingAndClearsBadgeOnce( + rawBranch: String + ) async throws { + let branch = try #require(GitMetadataService.normalizedBranchName(rawBranch)) let host = RecordingSidebarGitHost() host.pollingEnabled = true let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") - let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) - host.workspaces[0].state.panels[panelId]?.branch = SidebarPanelGitBranch( - branch: "feature/x", - isDirty: true - ) - let clock = ManualGitPollClock() - let reader = GatedMetadataReader( - metadata: .repository(branch: "feature/x", isDirty: true), - gated: true - ) - let service = makeService(host: host, reader: reader, clock: clock) - - service.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "filesystemEvent" - ) - await clock.waitForSleeper() - await clock.resumeNext() - #expect(await reader.waitForTrackedPathEventGenerationProbe()) - - service.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "filesystemEvent" + host.workspaces[0].state.panels[panelId]?.badge = SidebarPullRequestBadge( + number: 42, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, + status: .open, + branch: branch ) - await clock.waitForSleeper() - await clock.resumeNext() - #expect(await waitUntil { service.workspaceGitProbeRerunPending(for: key) }) - #expect(service.workspaceGitProbeRerunPending(for: key)) - await reader.openGate() - - for _ in 0..<500 { - let immediateProbeSleeps = await clock.recordedDurations.filter { $0 == 0 }.count - if immediateProbeSleeps >= 3 { break } - await Task.yield() - } - let immediateProbeSleeps = await clock.recordedDurations.filter { $0 == 0 }.count - - #expect(immediateProbeSleeps == 3) - service.clearWorkspaceGitProbes(workspaceId: workspaceId) - } - - /// With PR polling disabled, a branch probe does not touch the PR seam. - @Test func pollingDisabledSuppressesPullRequestScheduling() async throws { - let host = RecordingSidebarGitHost() - host.pollingEnabled = false - let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") let clock = ManualGitPollClock() - let pullRequestProbing = RecordingPullRequestProbing() + let pullRequestService = makePullRequestService(host: host) let service = makeService( host: host, - reader: GatedMetadataReader(metadata: .repository(branch: "main")), + reader: GatedMetadataReader(metadata: .repository(branch: rawBranch)), clock: clock, - pullRequestProbing: pullRequestProbing - ) - - service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "test" - ) - await clock.waitForSleeper() - await clock.resumeNext() - - #expect(await waitUntil { - host.events.contains { event in - if case .gitBranch = event { return true } - return false + pullRequestProbing: pullRequestService + ) + var projectionEvents = host.projectionEvents().makeAsyncIterator() + + for _ in 1...2 { + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + await clock.waitForSleeper(duration: 0) + #expect(await clock.resumeFirst(duration: 0)) + var observedProjection = false + while let event = await projectionEvents.next() { + if case .gitBranch(workspaceId, panelId, branch, false) = event { + observedProjection = true + break + } } - }) - #expect(pullRequestProbing.scheduledRefreshes.isEmpty) - } - - /// A probe whose panel directory changes while the snapshot is in flight - /// is dropped: no projection lands for the stale directory. - @Test(.timeLimit(.minutes(1))) - func directoryChangeWhileProbeInFlightDropsTheApply() async throws { - let host = RecordingSidebarGitHost() - let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/old") - let clock = ManualGitPollClock() - let reader = GatedMetadataReader( - metadata: .repository(branch: "main"), - gated: true - ) - let service = makeService(host: host, reader: reader, clock: clock) - - service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "test" - ) - await clock.waitForSleeper() - await clock.resumeNext() - - // Wait until the snapshot probe has started reading, then move the - // panel to a different directory before letting the read finish. - #expect(await reader.waitForProbe()) - host.workspaces[0].state.panels[panelId]?.directory = "/tmp/new" - await reader.openGate() + #expect(observedProjection) + } - // The stale apply must clear the probe rather than project "main". - #expect(await waitUntil { - service.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId).isEmpty - }) - #expect(host.workspaces[0].state.panels[panelId]?.branch == nil) - #expect(!host.events.contains { event in - if case .gitBranch = event { return true } + let badgeClearCount = host.events.filter { event in + if case .clearPullRequestBadge(workspaceId, panelId) = event { return true } return false - }) - } - - /// Disabling the git watch setting tears the subsystem down: all sidebar - /// git metadata cleared and the PR seam reset. - @Test func disablingWatchSettingClearsMetadataAndResetsPullRequests() async throws { - let host = RecordingSidebarGitHost() - host.addWorkspace(panelDirectory: "/tmp/repo") - let pullRequestProbing = RecordingPullRequestProbing() - let service = makeService( - host: host, - reader: GatedMetadataReader(metadata: .nonRepository), - clock: ManualGitPollClock(), - pullRequestProbing: pullRequestProbing - ) - - host.watchEnabled = false - service.sidebarGitMetadataWatchSettingsDidChange() - - #expect(host.events.contains(.clearAllGitMetadata)) - #expect(pullRequestProbing.resetCount == 1) + }.count + #expect(badgeClearCount == 1) + #expect(host.orderedWorkspaceIdsReadCount == 0) + #expect(host.panelGitBranchPanelIdsReadCount == 0) + #expect(host.panelPullRequestPanelIdsReadCount == 0) } - /// Closing a workspace clears its probe state and PR tracking. - @Test func clearWorkspaceGitProbesDropsTrackingForThatWorkspace() async throws { - let host = RecordingSidebarGitHost() - let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") - let clock = ManualGitPollClock() - let pullRequestProbing = RecordingPullRequestProbing() - let service = makeService( - host: host, - reader: GatedMetadataReader(metadata: .nonRepository, gated: true), - clock: clock, - pullRequestProbing: pullRequestProbing - ) - - service.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "test" - ) - await clock.waitForSleeper() - #expect(!service.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId).isEmpty) - - service.clearWorkspaceGitProbes(workspaceId: workspaceId) - - #expect(service.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId).isEmpty) - #expect(pullRequestProbing.clearedTrackingWorkspaceIds == [workspaceId]) - } } diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests+Invalidation.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests+Invalidation.swift new file mode 100644 index 000000000000..aef39165470c --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests+Invalidation.swift @@ -0,0 +1,276 @@ +import Foundation +import Testing +@testable import CmuxGit +@testable import CmuxSidebarGit + +extension ProbeSnapshotCacheTests { + @Test(.timeLimit(.minutes(1))) + func filesystemEventGenerationIsPassedToMetadataReader() async throws { + let directory = "/tmp/repo" + let host = RecordingSidebarGitHost() + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: directory) + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + let clock = ManualGitPollClock() + let reader = GatedMetadataReader(metadata: .repository(branch: "feature/x")) + let service = makeService(host: host, reader: reader, clock: clock) + + service.workspaceGitTrackedDirectoryByKey[key] = directory + service.markWorkspaceGitSnapshotCacheEligible(directory: directory) + let initialGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) + service.recordWorkspaceGitMetadataFilesystemEvent(for: key) + let eventGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) + #expect(eventGeneration != initialGeneration) + + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + await clock.waitForSleeper() + await clock.resumeNext() + #expect(await reader.waitForTrackedPathEventGenerationProbe()) + + let requests = await reader.probedSnapshotRequests + let request = try #require(requests.first ?? nil) + #expect(requests.count == 1) + guard case .watcherEvent = request else { + Issue.record("Expected watcher-event snapshot request") + return + } + } + + @Test func reusedWatcherMovesCacheGenerationToNewDirectory() throws { + let oldDirectory = "/tmp/repo" + let newDirectory = "/tmp/repo/nested" + let host = RecordingSidebarGitHost() + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: oldDirectory) + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), + clock: ManualGitPollClock() + ) + + service.setWorkspaceGitMetadataWatcherSourceDirectory(oldDirectory, for: key) + service.markWorkspaceGitSnapshotCacheEligible(directory: oldDirectory) + let oldGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: oldDirectory)) + + service.moveWorkspaceGitSnapshotCacheEligibility(for: key, to: newDirectory) + let newGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: newDirectory)) + + #expect(service.workspaceGitSnapshotCacheGeneration(directory: oldDirectory) == nil) + #expect(newGeneration != oldGeneration) + service.recordWorkspaceGitMetadataFilesystemEvent(for: key) + #expect(service.workspaceGitSnapshotCacheGeneration(directory: newDirectory) != newGeneration) + } + + @Test func sharedWatcherDirectoryKeepsCacheEligibilityUntilLastWatcherStops() throws { + let directory = "/tmp/repo" + let host = RecordingSidebarGitHost() + let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) + let secondPanelId = UUID() + let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) + let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), + clock: ManualGitPollClock() + ) + + service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: firstKey) + service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: secondKey) + service.markWorkspaceGitSnapshotCacheEligible(directory: directory) + let generation = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) + + service.stopWorkspaceGitMetadataWatcher(for: firstKey) + #expect(service.workspaceGitSnapshotCacheGeneration(directory: directory) == generation) + service.stopWorkspaceGitMetadataWatcher(for: secondKey) + #expect(service.workspaceGitSnapshotCacheGeneration(directory: directory) == nil) + } + + @Test func sharedWatchedPathsEventBumpsDirectoryGenerationOnce() throws { + let directory = "/tmp/repo" + let host = RecordingSidebarGitHost() + let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) + let secondPanelId = UUID() + let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) + let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) + let watchedPathsKey = WorkspaceGitMetadataWatchedPathsKey(paths: ["/tmp/repo/.git/index"]) + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), + clock: ManualGitPollClock() + ) + + service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: firstKey) + service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: secondKey) + service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: firstKey) + service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: secondKey) + service.markWorkspaceGitSnapshotCacheEligible(directory: directory) + let initialGeneration = service.workspaceGitMetadataFilesystemEventGeneration + + let refreshedKeys = service.recordWorkspaceGitMetadataFilesystemEvent( + forWatchedPathsKey: watchedPathsKey + ) + + #expect(Set(refreshedKeys) == Set([firstKey, secondKey])) + #expect(service.workspaceGitMetadataFilesystemEventGeneration == initialGeneration + 1) + } + + @Test func sharedWatchedPathsEventAssignsSameGenerationToEveryDirectory() throws { + let firstDirectory = "/tmp/repo/frontend" + let secondDirectory = "/tmp/repo/backend" + let host = RecordingSidebarGitHost() + let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: firstDirectory) + let secondPanelId = UUID() + let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) + let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) + let watchedPathsKey = WorkspaceGitMetadataWatchedPathsKey(paths: ["/tmp/repo/.git/index"]) + let service = makeService( + host: host, + reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), + clock: ManualGitPollClock() + ) + + service.setWorkspaceGitMetadataWatcherSourceDirectory(firstDirectory, for: firstKey) + service.setWorkspaceGitMetadataWatcherSourceDirectory(secondDirectory, for: secondKey) + service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: firstKey) + service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: secondKey) + service.markWorkspaceGitSnapshotCacheEligible(directory: firstDirectory) + service.markWorkspaceGitSnapshotCacheEligible(directory: secondDirectory) + let initialGeneration = service.workspaceGitMetadataFilesystemEventGeneration + + let refreshedKeys = service.recordWorkspaceGitMetadataFilesystemEvent( + forWatchedPathsKey: watchedPathsKey + ) + let firstGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: firstDirectory)) + let secondGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: secondDirectory)) + + #expect(Set(refreshedKeys) == Set([firstKey, secondKey])) + #expect(service.workspaceGitMetadataFilesystemEventGeneration == initialGeneration + 1) + #expect(firstGeneration == secondGeneration) + } + + @Test(.timeLimit(.minutes(1))) + func joinedSnapshotWithNewGenerationQueuesFreshFollowUpProbe() async throws { + let directory = "/tmp/repo" + let host = RecordingSidebarGitHost() + let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) + let secondPanelId = UUID() + host.workspaces[0].state.panels[secondPanelId] = RecordingSidebarGitHost.PanelState( + directory: directory + ) + let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) + let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) + let clock = ManualGitPollClock() + let reader = GatedMetadataReader( + metadata: .repository(branch: "feature/x"), + gated: true + ) + let service = makeService(host: host, reader: reader, clock: clock) + + service.workspaceGitTrackedDirectoryByKey[firstKey] = directory + service.workspaceGitTrackedDirectoryByKey[secondKey] = directory + service.markWorkspaceGitSnapshotCacheEligible(directory: directory) + let firstGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) + + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: firstPanelId, + reason: "filesystemEvent" + ) + await clock.waitForSleeper() + await clock.resumeNext() + #expect(await reader.waitForTrackedPathEventGenerationProbe(count: 1)) + + service.recordWorkspaceGitMetadataFilesystemEvent(for: secondKey) + let secondGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: secondPanelId, + reason: "filesystemEvent" + ) + await clock.waitForSleeper() + await clock.resumeNext() + #expect(await waitUntil { + service.workspaceGitProbeRerunPending(for: firstKey) + && service.workspaceGitProbeRerunPending(for: secondKey) + }) + + let requests = await reader.probedSnapshotRequests + #expect(secondGeneration != firstGeneration) + let request = try #require(requests.first ?? nil) + #expect(requests.count == 1) + guard case .watcherEvent = request else { + Issue.record("Expected watcher-event snapshot request") + return + } + #expect(service.workspaceGitProbeRerunPending(for: firstKey)) + #expect(service.workspaceGitProbeRerunPending(for: secondKey)) + await reader.openGate() + service.clearWorkspaceGitProbes(workspaceId: workspaceId) + } + + @Test(.timeLimit(.minutes(1))) + func watcherEventDuringFallbackProbeDoesNotApplyStaleSnapshot() async throws { + let directory = "/tmp/repo" + let host = RecordingSidebarGitHost() + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: directory) + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + let clock = ManualGitPollClock() + let reader = SequencedGatedMetadataReader(metadataByProbe: [ + .repository(branch: "feature/x", isDirty: false), + .repository(branch: "feature/x", isDirty: true), + ]) + let service = SidebarGitMetadataService( + workspaceGitMetadataReader: reader, + gitMetadataService: GitMetadataService(), + pullRequestProbing: RecordingPullRequestProbing(), + probeLimiter: WorkspaceGitMetadataProbeLimiter(limit: 2), + clock: clock + ) + let metrics = CmuxSidebarGitRuntimeMetrics() + metrics.reset(enable: true) + service.runtimeMetricsRecorder = metrics + service.attach(host: host) + service.workspaceGitTrackedDirectoryByKey[key] = directory + service.markWorkspaceGitSnapshotCacheEligible(directory: directory) + + service.refreshTrackedWorkspaceGitMetadata(reason: "fallbackTimer") + await clock.waitForSleeper(duration: 0) + await clock.resumeNext(duration: 0) + await reader.waitForProbeCount(1) + + service.recordWorkspaceGitMetadataFilesystemEvent(for: key) + service.scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: workspaceId, + panelId: panelId, + reason: "filesystemEvent" + ) + #expect(service.workspaceGitProbeRerunPending(for: key)) + + await reader.releaseProbe(at: 0) + #expect(await waitUntil { + service.workspaceGitSnapshotTasksByDirectory[directory] == nil + }) + await clock.waitForSleeper(duration: 0) + + // The stale completion has drained, but the replacement task remains + // parked on its zero-delay clock. Suppression cannot depend on the new + // scan beginning. + #expect(await reader.probeCount == 1) + #expect(await clock.pendingSleeperCount > 0) + #expect(host.workspaces[0].state.panels[panelId]?.branch == nil) + let rejected = service.runtimeMetricsRecorder.snapshot() + #expect(rejected.gitStaleCompletionRejectedOffMainCount == 1) + #expect(rejected.gitMainActorApplyEnteredCount == 0) + + await clock.resumeNext(duration: 0) + await reader.waitForProbeCount(2) + + #expect(host.workspaces[0].state.panels[panelId]?.branch == nil) + + await reader.releaseProbe(at: 1) + service.clearWorkspaceGitProbes(workspaceId: workspaceId) + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests.swift index 43d865b9cf60..46b66145f6b5 100644 --- a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests.swift +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSnapshotCacheTests.swift @@ -1,11 +1,125 @@ import Foundation import Testing -import CmuxGit +@testable import CmuxGit @testable import CmuxSidebarGit +final class GatedCountingSidebarGitFileStatusReader: GitFileStatusReading, @unchecked Sendable { + private let condition = NSCondition() + private let systemReader = SystemGitFileStatusReader() + private let gatedPath: String + private var callsByPath: [String: Int] = [:] + private var isGateOpen = false + private var countWaiters: [(path: String, minimumCount: Int, continuation: CheckedContinuation)] = [] + + init(gatedPath: String) { + self.gatedPath = gatedPath + } + + func status(atPath path: String) -> GitFileStatus? { + condition.lock() + callsByPath[path, default: 0] += 1 + let readyWaiters = countWaiters.filter { + $0.path == path && callsByPath[path, default: 0] >= $0.minimumCount + } + countWaiters.removeAll { waiter in + readyWaiters.contains { $0.path == waiter.path && $0.minimumCount == waiter.minimumCount } + } + for waiter in readyWaiters { + waiter.continuation.resume() + } + while path == gatedPath, !isGateOpen { + condition.wait() + } + condition.unlock() + return systemReader.status(atPath: path) + } + + func waitForCallCount(atPath path: String, atLeast minimumCount: Int) async { + await withCheckedContinuation { continuation in + condition.lock() + if callsByPath[path, default: 0] >= minimumCount { + condition.unlock() + continuation.resume() + } else { + countWaiters.append((path, minimumCount, continuation)) + condition.unlock() + } + } + } + + func callCount(atPath path: String) -> Int { + condition.lock() + defer { condition.unlock() } + return callsByPath[path] ?? 0 + } + + func openGate() { + condition.lock() + isGateOpen = true + condition.broadcast() + condition.unlock() + } +} + +actor SequencedGatedMetadataReader: WorkspaceGitMetadataReading { + private let metadataByProbe: [GitWorkspaceMetadata] + private var startedProbeCount = 0 + private var releasedProbeIndexes: Set = [] + private var gateWaitersByProbeIndex: [Int: CheckedContinuation] = [:] + private var probeCountWaiters: [(minimumCount: Int, continuation: CheckedContinuation)] = [] + + init(metadataByProbe: [GitWorkspaceMetadata]) { + self.metadataByProbe = metadataByProbe + } + + func workspaceMetadata(for directory: String) async -> GitWorkspaceMetadata { + await workspaceMetadata(for: directory, snapshotRequest: nil) + } + + func workspaceMetadata( + for directory: String, + snapshotRequest: GitTrackedChangesSnapshotRequest? + ) async -> GitWorkspaceMetadata { + let probeIndex = startedProbeCount + startedProbeCount += 1 + let readyCountWaiters = probeCountWaiters.filter { startedProbeCount >= $0.minimumCount } + probeCountWaiters.removeAll { startedProbeCount >= $0.minimumCount } + for waiter in readyCountWaiters { + waiter.continuation.resume() + } + if !releasedProbeIndexes.contains(probeIndex) { + await withCheckedContinuation { continuation in + if releasedProbeIndexes.contains(probeIndex) { + continuation.resume() + } else { + gateWaitersByProbeIndex[probeIndex] = continuation + } + } + } + return metadataByProbe[min(probeIndex, metadataByProbe.count - 1)] + } + + func releaseProbe(at index: Int) { + releasedProbeIndexes.insert(index) + gateWaitersByProbeIndex.removeValue(forKey: index)?.resume() + } + + func waitForProbeCount(_ minimumCount: Int) async { + while startedProbeCount < minimumCount { + await withCheckedContinuation { continuation in + probeCountWaiters.append((minimumCount, continuation)) + } + } + } + + var probeCount: Int { + startedProbeCount + } +} + @MainActor @Suite struct ProbeSnapshotCacheTests { - private func makeService( + func makeService( host: RecordingSidebarGitHost, reader: GatedMetadataReader, clock: ManualGitPollClock @@ -21,47 +135,170 @@ import CmuxGit return service } - private func waitUntil(maxYields: Int = 5_000, _ predicate: () -> Bool) async -> Bool { - for _ in 0.. Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { if predicate() { return true } - await Task.yield() + try? await clock.sleep(for: .milliseconds(1)) } return predicate() } @Test(.timeLimit(.minutes(1))) - func fallbackRefreshBypassesTrackedSnapshotCacheGeneration() async throws { + func consecutiveFallbackTicksUseDistinctSnapshotAuthority() async throws { let directory = "/tmp/repo" let host = RecordingSidebarGitHost() let (workspaceId, panelId) = host.addWorkspace(panelDirectory: directory) let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) - let clock = ManualGitPollClock() let reader = GatedMetadataReader(metadata: .repository(branch: "feature/x")) - let service = makeService(host: host, reader: reader, clock: clock) - + let clock = ManualGitPollClock() + let service = makeService( + host: host, + reader: reader, + clock: clock + ) service.workspaceGitTrackedDirectoryByKey[key] = directory service.markWorkspaceGitSnapshotCacheEligible(directory: directory) - let initialGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) + var events = host.projectionEvents().makeAsyncIterator() - service.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "fallbackTimer" + service.refreshTrackedWorkspaceGitMetadata(reason: "fallbackTimer") + await clock.waitForSleeper(duration: 0) + await clock.resumeNext(duration: 0) + while let event = await events.next() { + if case .gitBranch = event { break } + } + service.refreshTrackedWorkspaceGitMetadata(reason: "fallbackTimer") + await clock.waitForSleeper(duration: 0) + await clock.resumeNext(duration: 0) + while let event = await events.next() { + if case .gitBranch = event { break } + } + + let generations = await reader.probedFallbackRoundIDs + let firstRound = try #require(generations.first) + let secondRound = try #require(generations.last) + #expect(generations.count == 2) + #expect(firstRound != secondRound) + } + + @Test(.timeLimit(.minutes(1))) + func twoSidebarWindowsShareOneFallbackScanForNestedDirectories() async throws { + let fixture = try SidebarGitRepositoryFixture() + let firstDirectory = fixture.root.path + let secondDirectory = fixture.nestedDirectory.path + let firstTrackedPath = fixture.trackedFile.path + let fileStatusReader = GatedCountingSidebarGitFileStatusReader(gatedPath: firstTrackedPath) + let sharedScope = GitTrackedChangesSnapshotScope() + let sharedGitMetadataService = GitMetadataService( + fileStatusReader: fileStatusReader, + trackedChangesSnapshotScope: sharedScope ) - await clock.waitForSleeper() - await clock.resumeNext() - #expect(await reader.waitForTrackedPathEventGenerationProbe()) + let firstHost = RecordingSidebarGitHost() + let secondHost = RecordingSidebarGitHost() + let (firstWorkspaceId, firstPanelId) = firstHost.addWorkspace(panelDirectory: firstDirectory) + let (secondWorkspaceId, secondPanelId) = secondHost.addWorkspace(panelDirectory: secondDirectory) + let firstKey = WorkspaceGitProbeKey(workspaceId: firstWorkspaceId, panelId: firstPanelId) + let secondKey = WorkspaceGitProbeKey(workspaceId: secondWorkspaceId, panelId: secondPanelId) + let firstClock = ManualGitPollClock() + let secondClock = ManualGitPollClock() + let fallbackClock = ManualGitPollClock() + let fallbackCoordinator = WorkspaceGitFallbackCoordinator( + clock: fallbackClock + ) + let sharedLimiter = WorkspaceGitMetadataProbeLimiter(limit: 1) + let firstService = SidebarGitMetadataService( + workspaceGitMetadataReader: sharedGitMetadataService, + gitMetadataService: sharedGitMetadataService, + pullRequestProbing: RecordingPullRequestProbing(), + probeLimiter: sharedLimiter, + clock: firstClock, + fallbackCoordinator: fallbackCoordinator + ) + let secondService = SidebarGitMetadataService( + workspaceGitMetadataReader: sharedGitMetadataService, + gitMetadataService: sharedGitMetadataService, + pullRequestProbing: RecordingPullRequestProbing(), + probeLimiter: sharedLimiter, + clock: secondClock, + fallbackCoordinator: fallbackCoordinator + ) + firstService.attach(host: firstHost) + secondService.attach(host: secondHost) + firstService.workspaceGitTrackedDirectoryByKey[firstKey] = firstDirectory + secondService.workspaceGitTrackedDirectoryByKey[secondKey] = secondDirectory + firstService.markWorkspaceGitSnapshotCacheEligible(directory: firstDirectory) + secondService.markWorkspaceGitSnapshotCacheEligible(directory: secondDirectory) + firstService.updateWorkspaceGitMetadataFallbackTimer() + secondService.updateWorkspaceGitMetadataFallbackTimer() + var firstEvents = firstHost.projectionEvents().makeAsyncIterator() + var secondEvents = secondHost.projectionEvents().makeAsyncIterator() + defer { + fileStatusReader.openGate() + firstService.clearWorkspaceGitProbes(workspaceId: firstWorkspaceId) + secondService.clearWorkspaceGitProbes(workspaceId: secondWorkspaceId) + } - let generations = await reader.probedTrackedPathEventGenerations - #expect(generations == [nil]) - let advancedGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - #expect(advancedGeneration != initialGeneration) + await fallbackClock.waitForSleeper(duration: 5 * 60) + #expect(await fallbackClock.recordedDurations == [5 * 60]) + await fallbackClock.resumeNext(duration: 5 * 60) + await firstClock.waitForSleeper(duration: 0) + await secondClock.waitForSleeper(duration: 0) + await firstClock.resumeNext(duration: 0) + await secondClock.resumeNext(duration: 0) + await fileStatusReader.waitForCallCount(atPath: firstTrackedPath, atLeast: 1) + fileStatusReader.openGate() + while let event = await firstEvents.next() { + if case .gitBranch = event { break } + } + while let event = await secondEvents.next() { + if case .gitBranch = event { break } + } + + #expect(fileStatusReader.callCount(atPath: firstTrackedPath) == 1) } @Test(.timeLimit(.minutes(1))) - func branchChangeBypassesTrackedSnapshotCacheGeneration() async throws { + func fallbackCoordinatorDoesNotRetainReleasedWindow() async { + let fallbackClock = ManualGitPollClock() + let coordinator = WorkspaceGitFallbackCoordinator(clock: fallbackClock) + let host = RecordingSidebarGitHost() + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + var service: SidebarGitMetadataService? = SidebarGitMetadataService( + workspaceGitMetadataReader: GatedMetadataReader( + metadata: .repository(branch: "feature/x") + ), + gitMetadataService: GitMetadataService(), + pullRequestProbing: RecordingPullRequestProbing(), + probeLimiter: WorkspaceGitMetadataProbeLimiter(limit: 1), + clock: ManualGitPollClock(), + fallbackCoordinator: coordinator + ) + weak let weakService = service + service?.attach(host: host) + service?.workspaceGitTrackedDirectoryByKey[key] = "/tmp/repo" + service?.updateWorkspaceGitMetadataFallbackTimer() + + await fallbackClock.waitForSleeper(duration: 5 * 60) + #expect(await fallbackClock.pendingSleeperCount == 1) + + service = nil + #expect(weakService == nil) + coordinator.serviceStateDidChange() + while await fallbackClock.pendingSleeperCount != 0 { + await Task.yield() + } + #expect(await fallbackClock.pendingSleeperCount == 0) + } + + @Test(.timeLimit(.minutes(1))) + func fallbackRefreshStartsNewSnapshotCacheRound() async throws { let directory = "/tmp/repo" let host = RecordingSidebarGitHost() let (workspaceId, panelId) = host.addWorkspace(panelDirectory: directory) @@ -72,25 +309,22 @@ import CmuxGit service.workspaceGitTrackedDirectoryByKey[key] = directory service.markWorkspaceGitSnapshotCacheEligible(directory: directory) + let initialGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - service.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: panelId, - reason: "branchChange" - ) - await clock.waitForSleeper() - await clock.resumeNext() + service.refreshTrackedWorkspaceGitMetadata(reason: "fallbackTimer") + await clock.waitForSleeper(duration: 0) + await clock.resumeNext(duration: 0) #expect(await reader.waitForTrackedPathEventGenerationProbe()) - let generations = await reader.probedTrackedPathEventGenerations - #expect(generations == [nil]) + let generations = await reader.probedFallbackRoundIDs + let generation = try #require(generations.first) + #expect(generations.count == 1) + #expect(generation.sequence > 0) + #expect(service.workspaceGitSnapshotCacheGeneration(directory: directory) == initialGeneration) } - @Test( - .timeLimit(.minutes(1)), - arguments: ["directoryChange", "branchCleared", "unexpectedReason"] - ) - func nonWatcherRefreshReasonsBypassTrackedSnapshotCacheGeneration(reason: String) async throws { + @Test(.timeLimit(.minutes(1))) + func branchChangeBypassesTrackedSnapshotCacheGeneration() async throws { let directory = "/tmp/repo" let host = RecordingSidebarGitHost() let (workspaceId, panelId) = host.addWorkspace(panelDirectory: directory) @@ -105,7 +339,7 @@ import CmuxGit service.scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, panelId: panelId, - reason: reason + reason: "branchChange" ) await clock.waitForSleeper() await clock.resumeNext() @@ -115,8 +349,11 @@ import CmuxGit #expect(generations == [nil]) } - @Test(.timeLimit(.minutes(1))) - func filesystemEventGenerationIsPassedToMetadataReader() async throws { + @Test( + .timeLimit(.minutes(1)), + arguments: ["directoryChange", "branchCleared", "unexpectedReason"] + ) + func nonWatcherRefreshReasonsBypassTrackedSnapshotCacheGeneration(reason: String) async throws { let directory = "/tmp/repo" let host = RecordingSidebarGitHost() let (workspaceId, panelId) = host.addWorkspace(panelDirectory: directory) @@ -127,194 +364,18 @@ import CmuxGit service.workspaceGitTrackedDirectoryByKey[key] = directory service.markWorkspaceGitSnapshotCacheEligible(directory: directory) - let initialGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - service.recordWorkspaceGitMetadataFilesystemEvent(for: key) - let eventGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - #expect(eventGeneration != initialGeneration) service.scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, panelId: panelId, - reason: "filesystemEvent" + reason: reason ) await clock.waitForSleeper() await clock.resumeNext() #expect(await reader.waitForTrackedPathEventGenerationProbe()) let generations = await reader.probedTrackedPathEventGenerations - let generation = try #require(generations.first ?? nil) - #expect(generations.count == 1) - #expect(generation.namespace == service.workspaceGitSnapshotCacheNamespace) - #expect(generation.generation == eventGeneration) - } - - @Test func reusedWatcherMovesCacheGenerationToNewDirectory() throws { - let oldDirectory = "/tmp/repo" - let newDirectory = "/tmp/repo/nested" - let host = RecordingSidebarGitHost() - let (workspaceId, panelId) = host.addWorkspace(panelDirectory: oldDirectory) - let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) - let service = makeService( - host: host, - reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), - clock: ManualGitPollClock() - ) - - service.setWorkspaceGitMetadataWatcherSourceDirectory(oldDirectory, for: key) - service.markWorkspaceGitSnapshotCacheEligible(directory: oldDirectory) - let oldGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: oldDirectory)) - - service.moveWorkspaceGitSnapshotCacheEligibility(for: key, to: newDirectory) - let newGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: newDirectory)) - - #expect(service.workspaceGitSnapshotCacheGeneration(directory: oldDirectory) == nil) - #expect(newGeneration != oldGeneration) - service.recordWorkspaceGitMetadataFilesystemEvent(for: key) - #expect(service.workspaceGitSnapshotCacheGeneration(directory: newDirectory) != newGeneration) - } - - @Test func sharedWatcherDirectoryKeepsCacheEligibilityUntilLastWatcherStops() throws { - let directory = "/tmp/repo" - let host = RecordingSidebarGitHost() - let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) - let secondPanelId = UUID() - let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) - let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) - let service = makeService( - host: host, - reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), - clock: ManualGitPollClock() - ) - - service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: firstKey) - service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: secondKey) - service.markWorkspaceGitSnapshotCacheEligible(directory: directory) - let generation = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - - service.stopWorkspaceGitMetadataWatcher(for: firstKey) - #expect(service.workspaceGitSnapshotCacheGeneration(directory: directory) == generation) - service.stopWorkspaceGitMetadataWatcher(for: secondKey) - #expect(service.workspaceGitSnapshotCacheGeneration(directory: directory) == nil) - } - - @Test func sharedWatchedPathsEventBumpsDirectoryGenerationOnce() throws { - let directory = "/tmp/repo" - let host = RecordingSidebarGitHost() - let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) - let secondPanelId = UUID() - let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) - let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) - let watchedPathsKey = WorkspaceGitMetadataWatchedPathsKey(paths: ["/tmp/repo/.git/index"]) - let service = makeService( - host: host, - reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), - clock: ManualGitPollClock() - ) - - service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: firstKey) - service.setWorkspaceGitMetadataWatcherSourceDirectory(directory, for: secondKey) - service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: firstKey) - service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: secondKey) - service.markWorkspaceGitSnapshotCacheEligible(directory: directory) - let initialGeneration = service.workspaceGitMetadataFilesystemEventGeneration - - let refreshedKeys = service.recordWorkspaceGitMetadataFilesystemEvent( - forWatchedPathsKey: watchedPathsKey - ) - - #expect(Set(refreshedKeys) == Set([firstKey, secondKey])) - #expect(service.workspaceGitMetadataFilesystemEventGeneration == initialGeneration + 1) - } - - @Test func sharedWatchedPathsEventAssignsSameGenerationToEveryDirectory() throws { - let firstDirectory = "/tmp/repo/frontend" - let secondDirectory = "/tmp/repo/backend" - let host = RecordingSidebarGitHost() - let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: firstDirectory) - let secondPanelId = UUID() - let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) - let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) - let watchedPathsKey = WorkspaceGitMetadataWatchedPathsKey(paths: ["/tmp/repo/.git/index"]) - let service = makeService( - host: host, - reader: GatedMetadataReader(metadata: .repository(branch: "feature/x")), - clock: ManualGitPollClock() - ) - - service.setWorkspaceGitMetadataWatcherSourceDirectory(firstDirectory, for: firstKey) - service.setWorkspaceGitMetadataWatcherSourceDirectory(secondDirectory, for: secondKey) - service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: firstKey) - service.setWorkspaceGitMetadataWatcherWatchedPathsKey(watchedPathsKey, for: secondKey) - service.markWorkspaceGitSnapshotCacheEligible(directory: firstDirectory) - service.markWorkspaceGitSnapshotCacheEligible(directory: secondDirectory) - let initialGeneration = service.workspaceGitMetadataFilesystemEventGeneration - - let refreshedKeys = service.recordWorkspaceGitMetadataFilesystemEvent( - forWatchedPathsKey: watchedPathsKey - ) - let firstGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: firstDirectory)) - let secondGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: secondDirectory)) - - #expect(Set(refreshedKeys) == Set([firstKey, secondKey])) - #expect(service.workspaceGitMetadataFilesystemEventGeneration == initialGeneration + 1) - #expect(firstGeneration == secondGeneration) + #expect(generations == [nil]) } - @Test(.timeLimit(.minutes(1))) - func joinedSnapshotWithNewGenerationQueuesFreshFollowUpProbe() async throws { - let directory = "/tmp/repo" - let host = RecordingSidebarGitHost() - let (workspaceId, firstPanelId) = host.addWorkspace(panelDirectory: directory) - let secondPanelId = UUID() - host.workspaces[0].state.panels[secondPanelId] = RecordingSidebarGitHost.PanelState( - directory: directory - ) - let firstKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: firstPanelId) - let secondKey = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: secondPanelId) - let clock = ManualGitPollClock() - let reader = GatedMetadataReader( - metadata: .repository(branch: "feature/x"), - gated: true - ) - let service = makeService(host: host, reader: reader, clock: clock) - - service.workspaceGitTrackedDirectoryByKey[firstKey] = directory - service.workspaceGitTrackedDirectoryByKey[secondKey] = directory - service.markWorkspaceGitSnapshotCacheEligible(directory: directory) - let firstGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - - service.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: firstPanelId, - reason: "filesystemEvent" - ) - await clock.waitForSleeper() - await clock.resumeNext() - #expect(await reader.waitForTrackedPathEventGenerationProbe(count: 1)) - - service.recordWorkspaceGitMetadataFilesystemEvent(for: secondKey) - let secondGeneration = try #require(service.workspaceGitSnapshotCacheGeneration(directory: directory)) - service.scheduleWorkspaceGitMetadataRefreshIfPossible( - workspaceId: workspaceId, - panelId: secondPanelId, - reason: "filesystemEvent" - ) - await clock.waitForSleeper() - await clock.resumeNext() - #expect(await waitUntil { - service.workspaceGitProbeRerunPending(for: firstKey) - && service.workspaceGitProbeRerunPending(for: secondKey) - }) - - let generations = await reader.probedTrackedPathEventGenerations - #expect(secondGeneration != firstGeneration) - let generation = try #require(generations.first ?? nil) - #expect(generations.count == 1) - #expect(generation.namespace == service.workspaceGitSnapshotCacheNamespace) - #expect(generation.generation == firstGeneration) - #expect(service.workspaceGitProbeRerunPending(for: firstKey)) - #expect(service.workspaceGitProbeRerunPending(for: secondKey)) - await reader.openGate() - service.clearWorkspaceGitProbes(workspaceId: workspaceId) - } } diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/PullRequestPollServiceOwnerProofTests.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/PullRequestPollServiceOwnerProofTests.swift new file mode 100644 index 000000000000..37d01bafe337 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/PullRequestPollServiceOwnerProofTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing +@testable import CmuxSidebarGit + +@MainActor +@Suite struct PullRequestPollServiceOwnerProofTests { + private func makeService( + host: RecordingSidebarGitHost, + executor: any PullRequestRefreshExecuting + ) -> PullRequestPollService { + let service = PullRequestPollService( + refreshExecutor: executor, + clock: ManualGitPollClock() + ) + let metrics = CmuxSidebarGitRuntimeMetrics() + metrics.reset(enable: true) + service.runtimeMetricsRecorder = metrics + service.attach(host: host) + return service + } + + @Test(.timeLimit(.minutes(1))) + func equivalentOverlappingRefreshesJoinBeforeTraversalAndRunOneRepoFetch() async throws { + let host = RecordingSidebarGitHost() + host.pollingEnabled = true + let (workspaceId, panelId) = host.addWorkspace(panelDirectory: "/tmp/repo") + host.workspaces[0].state.panels[panelId]?.branch = SidebarPanelGitBranch( + branch: "feature/x", + isDirty: false + ) + let executor = GatedPullRequestRefreshExecutor() + let service = makeService(host: host, executor: executor) + + let requestCount = 8 + service.refreshTrackedWorkspacePullRequestsIfNeeded(reason: "timer") + await executor.waitForFetchCount(1) + for _ in 1.. + } + private let metadata: GitWorkspaceMetadata private let gated: Bool private var gateWaiters: [CheckedContinuation] = [] + private var probeWaitersByID: [UUID: ProbeWaiter] = [:] private var isOpen = false private(set) var probedDirectories: [String] = [] - private(set) var probedTrackedPathEventGenerations: [GitTrackedPathEventGeneration?] = [] + private(set) var probedSnapshotRequests: [GitTrackedChangesSnapshotRequest?] = [] + + var probedTrackedPathEventGenerations: [GitTrackedPathEventGeneration?] { + probedSnapshotRequests.map { request in + switch request { + case .fallbackRound: + nil + case .watcherEvent(_, let eventID): + eventID + case nil: + nil + } + } + } + + var probedFallbackRoundIDs: [GitFallbackRoundID] { + probedSnapshotRequests.compactMap { request in + guard case .fallbackRound(let id, _) = request else { return nil } + return id + } + } init(metadata: GitWorkspaceMetadata, gated: Bool = false) { self.metadata = metadata @@ -26,39 +52,90 @@ actor GatedMetadataReader: WorkspaceGitMetadataReading { } } - func waitForTrackedPathEventGenerationProbe( + nonisolated func waitForTrackedPathEventGenerationProbe( count minimumCount: Int = 1, - maxYields: Int = 5_000 + timeout: Duration = .seconds(2) + ) async -> Bool { + await waitForProbeArrival(count: minimumCount, timeout: timeout) + } + + nonisolated func waitForProbe( + count minimumCount: Int = 1, + timeout: Duration = .seconds(2) + ) async -> Bool { + await waitForProbeArrival(count: minimumCount, timeout: timeout) + } + + private nonisolated func waitForProbeArrival( + count minimumCount: Int, + timeout: Duration ) async -> Bool { - for _ in 0..= minimumCount { - return true + await withTaskGroup(of: Bool.self) { group in + group.addTask { + await self.waitForProbeArrival(count: minimumCount) + } + group.addTask { + do { + try await Task.sleep(for: timeout) + return false + } catch { + return false + } } - await Task.yield() + let didArrive = await group.next() ?? false + group.cancelAll() + return didArrive } - return probedTrackedPathEventGenerations.count >= minimumCount } - func waitForProbe(count minimumCount: Int = 1, maxYields: Int = 5_000) async -> Bool { - for _ in 0..= minimumCount { - return true + private func waitForProbeArrival(count minimumCount: Int) async -> Bool { + if probedDirectories.count >= minimumCount { + return true + } + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + probeWaitersByID[waiterID] = ProbeWaiter( + minimumCount: minimumCount, + continuation: continuation + ) + } + } onCancel: { + Task { + await self.cancelProbeWaiter(waiterID) } - await Task.yield() } - return probedDirectories.count >= minimumCount + } + + private func cancelProbeWaiter(_ waiterID: UUID) { + probeWaitersByID.removeValue(forKey: waiterID)?.continuation.resume(returning: false) + } + + private func resumeSatisfiedProbeWaiters() { + let probeCount = probedDirectories.count + let satisfiedWaiterIDs = probeWaitersByID.compactMap { id, waiter in + waiter.minimumCount <= probeCount ? id : nil + } + for waiterID in satisfiedWaiterIDs { + probeWaitersByID.removeValue(forKey: waiterID)?.continuation.resume(returning: true) + } } func workspaceMetadata(for directory: String) async -> GitWorkspaceMetadata { - await workspaceMetadata(for: directory, trackedPathEventGeneration: nil) + await workspaceMetadata(for: directory, snapshotRequest: nil) } func workspaceMetadata( for directory: String, - trackedPathEventGeneration: GitTrackedPathEventGeneration? + snapshotRequest: GitTrackedChangesSnapshotRequest? ) async -> GitWorkspaceMetadata { probedDirectories.append(directory) - probedTrackedPathEventGenerations.append(trackedPathEventGeneration) + probedSnapshotRequests.append(snapshotRequest) + resumeSatisfiedProbeWaiters() if !isOpen { await withCheckedContinuation { (continuation: CheckedContinuation) in if isOpen { @@ -75,10 +152,16 @@ actor GatedMetadataReader: WorkspaceGitMetadataReading { /// Records every call the git metadata service makes into the PR seam. @MainActor final class RecordingPullRequestProbing: PullRequestProbing { + private struct SourceIdentity: Equatable { + let directory: String + let branch: String + } + private(set) var scheduledRefreshes: [(workspaceId: UUID, panelId: UUID, reason: String)] = [] private(set) var clearedTrackingKeys: [(workspaceId: UUID, panelId: UUID)] = [] private(set) var clearedTrackingWorkspaceIds: [UUID] = [] var trackedPanelIdsByWorkspace: [UUID: Set] = [:] + private var sourceByKey: [WorkspaceGitProbeKey: SourceIdentity] = [:] private(set) var resetCount = 0 func attach(host: any SidebarGitHosting) {} @@ -86,21 +169,46 @@ final class RecordingPullRequestProbing: PullRequestProbing { scheduledRefreshes.append((workspaceId, panelId, reason)) trackedPanelIdsByWorkspace[workspaceId, default: []].insert(panelId) } + func seedWorkspacePullRequestRefreshIfNeeded( + workspaceId: UUID, + panelId: UUID, + directory: String, + branch: String, + reason: String + ) { + guard let normalizedBranch = GitMetadataService.normalizedBranchName(branch) else { return } + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + let source = SourceIdentity( + directory: directory.normalizedGitProbeDirectory, + branch: normalizedBranch + ) + guard sourceByKey[key] != source else { return } + sourceByKey[key] = source + guard !PullRequestProbeService.shouldSkipLookup(branch: normalizedBranch) else { return } + scheduleWorkspacePullRequestRefresh( + workspaceId: workspaceId, + panelId: panelId, + reason: reason + ) + } func refreshTrackedWorkspacePullRequestsIfNeeded(reason: String) {} func sidebarPullRequestPollingSettingsDidChange() {} func handleWorkspacePullRequestCommandHint(workspaceId: UUID, panelId: UUID, action: String, target: String?) {} func clearWorkspacePullRequestTracking(workspaceId: UUID, panelId: UUID) { clearedTrackingKeys.append((workspaceId, panelId)) trackedPanelIdsByWorkspace[workspaceId]?.remove(panelId) + sourceByKey.removeValue(forKey: WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId)) } func clearWorkspacePullRequestMetadata(workspaceId: UUID, panelId: UUID) {} func clearWorkspacePullRequestTracking(workspaceId: UUID) { clearedTrackingWorkspaceIds.append(workspaceId) trackedPanelIdsByWorkspace[workspaceId] = [] + sourceByKey = sourceByKey.filter { $0.key.workspaceId != workspaceId } } func resetWorkspacePullRequestRefreshState() { resetCount += 1 trackedPanelIdsByWorkspace.removeAll() + sourceByKey.removeAll() } func workspacePullRequestTrackedPanelIds(workspaceId: UUID) -> Set { trackedPanelIdsByWorkspace[workspaceId] ?? [] @@ -146,3 +254,94 @@ extension GitWorkspaceMetadata { headSignature: nil ) } + +/// A staged PR executor whose repository-fetch calls remain suspended until +/// the test explicitly releases them. This makes overlap and stale-completion +/// ordering deterministic without sleeps or live GitHub traffic. +actor GatedPullRequestRefreshExecutor: PullRequestRefreshExecuting { + private struct FetchWaiter { + let minimumCount: Int + let continuation: CheckedContinuation + } + + private var fetchGateContinuations: [CheckedContinuation] = [] + private var fetchWaiters: [FetchWaiter] = [] + private(set) var resolutionCount = 0 + private(set) var fetchCount = 0 + private(set) var allowCachedResultsRequests: [Bool] = [] + + func resolveCandidateSeeds( + _ seeds: [WorkspacePullRequestCandidateSeed] + ) async -> WorkspacePullRequestCandidateResolution { + resolutionCount += 1 + let candidates = seeds.map { + WorkspacePullRequestCandidate( + workspaceId: $0.workspaceId, + panelId: $0.panelId, + branch: $0.branch, + repoSlugs: ["owner/repo"] + ) + } + let branches = Set(candidates.map(\.branch)) + return WorkspacePullRequestCandidateResolution( + candidates: candidates, + candidateBranchesByRepo: ["owner/repo": branches], + repoDirectoriesBySlug: ["owner/repo": "/tmp/repo"] + ) + } + + func fetchRepoResults( + candidateResolution: WorkspacePullRequestCandidateResolution, + cacheBySlug: [String: WorkspacePullRequestRepoCacheEntry], + now: Date, + allowCachedResults: Bool + ) async -> [String: WorkspacePullRequestRepoFetchResult] { + fetchCount += 1 + allowCachedResultsRequests.append(allowCachedResults) + resumeSatisfiedFetchWaiters() + await withCheckedContinuation { continuation in + fetchGateContinuations.append(continuation) + } + + var pullRequestsByBranch: [String: GitHubPullRequestProbeItem] = [:] + for branch in candidateResolution.candidateBranchesByRepo["owner/repo"] ?? [] { + pullRequestsByBranch[branch] = GitHubPullRequestProbeItem( + number: 99, + state: "OPEN", + url: "https://github.com/owner/repo/pull/99", + updatedAt: "2026-07-12T00:00:00Z", + mergedAt: nil, + headRefName: branch, + baseRefName: "main" + ) + } + let entry = WorkspacePullRequestRepoCacheEntry( + fetchedAt: now, + pullRequestsByBranch: pullRequestsByBranch + ) + return ["owner/repo": .success(entry, usedCache: false, transientBranches: [])] + } + + func waitForFetchCount(_ minimumCount: Int) async { + guard fetchCount < minimumCount else { return } + await withCheckedContinuation { continuation in + fetchWaiters.append(FetchWaiter( + minimumCount: minimumCount, + continuation: continuation + )) + } + } + + func releaseNextFetch() { + guard !fetchGateContinuations.isEmpty else { return } + fetchGateContinuations.removeFirst().resume() + } + + private func resumeSatisfiedFetchWaiters() { + let ready = fetchWaiters.filter { $0.minimumCount <= fetchCount } + fetchWaiters.removeAll { $0.minimumCount <= fetchCount } + for waiter in ready { + waiter.continuation.resume() + } + } +} diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/ManualGitPollClock.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/ManualGitPollClock.swift index 0e345be983fc..0cae2488b238 100644 --- a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/ManualGitPollClock.swift +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/ManualGitPollClock.swift @@ -6,18 +6,27 @@ import Foundation actor ManualGitPollClock: GitPollClock { private struct Sleeper { let id: UUID + let duration: TimeInterval let continuation: CheckedContinuation } + private struct RecordedDurationWaiter { + let duration: TimeInterval + let minimumCount: Int + let continuation: CheckedContinuation + } + private(set) var recordedDurations: [TimeInterval] = [] private var sleepers: [Sleeper] = [] private var cancelledIds: Set = [] private var sleeperWaiters: [CheckedContinuation] = [] + private var recordedDurationWaitersByID: [UUID: RecordedDurationWaiter] = [:] func sleep(for duration: Duration) async throws { let seconds = TimeInterval(duration.components.seconds) + TimeInterval(duration.components.attoseconds) / 1e18 recordedDurations.append(seconds) + resumeSatisfiedRecordedDurationWaiters() let id = UUID() try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -25,7 +34,7 @@ actor ManualGitPollClock: GitPollClock { continuation.resume(throwing: CancellationError()) return } - sleepers.append(Sleeper(id: id, continuation: continuation)) + sleepers.append(Sleeper(id: id, duration: seconds, continuation: continuation)) while !sleeperWaiters.isEmpty { sleeperWaiters.removeFirst().resume() } @@ -46,16 +55,67 @@ actor ManualGitPollClock: GitPollClock { } } + /// Suspends until a sleeper with the requested virtual duration is parked. + func waitForSleeper(duration: TimeInterval) async { + while !sleepers.contains(where: { $0.duration == duration }) { + await withCheckedContinuation { (continuation: CheckedContinuation) in + sleeperWaiters.append(continuation) + } + } + } + + nonisolated func waitForRecordedDuration( + _ duration: TimeInterval, + count minimumCount: Int, + timeout: Duration = .seconds(2) + ) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + await self.waitForRecordedDuration(duration, count: minimumCount) + } + group.addTask { + do { + try await Task.sleep(for: timeout) + return false + } catch { + return false + } + } + let didRecord = await group.next() ?? false + group.cancelAll() + return didRecord + } + } + /// Resumes the oldest parked sleeper. func resumeNext() { guard !sleepers.isEmpty else { return } sleepers.removeFirst().continuation.resume(returning: ()) } + /// Resumes the oldest parked sleeper with the requested duration. + @discardableResult + func resumeFirst(duration: TimeInterval) -> Bool { + guard let index = sleepers.firstIndex(where: { $0.duration == duration }) else { + return false + } + sleepers.remove(at: index).continuation.resume(returning: ()) + return true + } + + /// Compatibility spelling used by the snapshot-coordination tests. + func resumeNext(duration: TimeInterval) { + _ = resumeFirst(duration: duration) + } + var lastRecordedDuration: TimeInterval? { recordedDurations.last } + var pendingSleeperCount: Int { + sleepers.count + } + private func cancelSleeper(id: UUID) { if let index = sleepers.firstIndex(where: { $0.id == id }) { sleepers.remove(at: index).continuation.resume(throwing: CancellationError()) @@ -63,4 +123,50 @@ actor ManualGitPollClock: GitPollClock { cancelledIds.insert(id) } } + + private func waitForRecordedDuration( + _ duration: TimeInterval, + count minimumCount: Int + ) async -> Bool { + if recordedDurationCount(duration) >= minimumCount { + return true + } + let waiterID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + recordedDurationWaitersByID[waiterID] = RecordedDurationWaiter( + duration: duration, + minimumCount: minimumCount, + continuation: continuation + ) + } + } onCancel: { + Task { + await self.cancelRecordedDurationWaiter(waiterID) + } + } + } + + private func cancelRecordedDurationWaiter(_ waiterID: UUID) { + recordedDurationWaitersByID.removeValue(forKey: waiterID)? + .continuation.resume(returning: false) + } + + private func resumeSatisfiedRecordedDurationWaiters() { + let satisfiedWaiterIDs = recordedDurationWaitersByID.compactMap { id, waiter in + recordedDurationCount(waiter.duration) >= waiter.minimumCount ? id : nil + } + for waiterID in satisfiedWaiterIDs { + recordedDurationWaitersByID.removeValue(forKey: waiterID)? + .continuation.resume(returning: true) + } + } + + private func recordedDurationCount(_ duration: TimeInterval) -> Int { + recordedDurations.lazy.filter { $0 == duration }.count + } } diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/RecordingSidebarGitHost.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/RecordingSidebarGitHost.swift index ebd1f556615c..92a2330ea164 100644 --- a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/RecordingSidebarGitHost.swift +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/RecordingSidebarGitHost.swift @@ -38,7 +38,13 @@ final class RecordingSidebarGitHost: SidebarGitHosting { var mobileHostActive = false var selectedWorkspaceId: UUID? private(set) var events: [ProjectionEvent] = [] + private(set) var orderedWorkspaceIdsReadCount = 0 + private(set) var panelGitBranchPanelIdsReadCount = 0 + private(set) var panelPullRequestPanelIdsReadCount = 0 private var eventContinuations: [AsyncStream.Continuation] = [] + private var orderedWorkspaceReadWaiters: [ + (minimumCount: Int, continuation: CheckedContinuation) + ] = [] /// A stream of projection events, registered before the awaited action. func projectionEvents() -> AsyncStream { @@ -47,6 +53,13 @@ final class RecordingSidebarGitHost: SidebarGitHosting { } } + func waitForOrderedWorkspaceIdsReadCount(_ minimumCount: Int) async { + guard orderedWorkspaceIdsReadCount < minimumCount else { return } + await withCheckedContinuation { continuation in + orderedWorkspaceReadWaiters.append((minimumCount, continuation)) + } + } + private func record(_ event: ProjectionEvent) { events.append(event) for continuation in eventContinuations { @@ -76,7 +89,19 @@ final class RecordingSidebarGitHost: SidebarGitHosting { // MARK: Reads - func orderedWorkspaceIds() -> [UUID] { workspaces.map(\.id) } + func orderedWorkspaceIds() -> [UUID] { + orderedWorkspaceIdsReadCount += 1 + let readyWaiters = orderedWorkspaceReadWaiters.filter { + orderedWorkspaceIdsReadCount >= $0.minimumCount + } + orderedWorkspaceReadWaiters.removeAll { + orderedWorkspaceIdsReadCount >= $0.minimumCount + } + for waiter in readyWaiters { + waiter.continuation.resume() + } + return workspaces.map(\.id) + } func workspaceExists(_ workspaceId: UUID) -> Bool { state(workspaceId) != nil } func isRemoteWorkspace(_ workspaceId: UUID) -> Bool? { state(workspaceId)?.isRemote } func panelIds(in workspaceId: UUID) -> [UUID] { @@ -101,6 +126,7 @@ final class RecordingSidebarGitHost: SidebarGitHosting { state(workspaceId)?.panels[panelId]?.branch } func panelGitBranchPanelIds(in workspaceId: UUID) -> Set { + panelGitBranchPanelIdsReadCount += 1 guard let state = state(workspaceId) else { return [] } return Set(state.panels.filter { $0.value.branch != nil }.keys) } @@ -108,6 +134,7 @@ final class RecordingSidebarGitHost: SidebarGitHosting { state(workspaceId)?.panels[panelId]?.badge } func panelPullRequestPanelIds(in workspaceId: UUID) -> Set { + panelPullRequestPanelIdsReadCount += 1 guard let state = state(workspaceId) else { return [] } return Set(state.panels.filter { $0.value.badge != nil }.keys) } diff --git a/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/SidebarGitRepositoryFixture.swift b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/SidebarGitRepositoryFixture.swift new file mode 100644 index 000000000000..549d8116bd4d --- /dev/null +++ b/Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/Support/SidebarGitRepositoryFixture.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Isolated one-file repository used by cross-window snapshot tests. +final class SidebarGitRepositoryFixture { + let root: URL + let gitDirectory: URL + let trackedFile: URL + let nestedDirectory: URL + + init(contents: String = "hello") throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-sidebar-git-tests-\(UUID().uuidString)", + isDirectory: true + ) + gitDirectory = root.appendingPathComponent(".git", isDirectory: true) + trackedFile = root.appendingPathComponent("file.txt") + nestedDirectory = root.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory( + at: gitDirectory.appendingPathComponent("refs/heads", isDirectory: true), + withIntermediateDirectories: true + ) + try "ref: refs/heads/main\n".write( + to: gitDirectory.appendingPathComponent("HEAD"), + atomically: true, + encoding: .utf8 + ) + try "\(String(repeating: "f", count: 40))\n".write( + to: gitDirectory.appendingPathComponent("refs/heads/main"), + atomically: true, + encoding: .utf8 + ) + try contents.write(to: trackedFile, atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: nestedDirectory, + withIntermediateDirectories: true + ) + try makeIndexData().write(to: gitDirectory.appendingPathComponent("index")) + } + + deinit { + try? FileManager.default.removeItem(at: root) + } + + private func makeIndexData() -> Data { + var fileStat = stat() + _ = lstat(trackedFile.path, &fileStat) + var bytes = Array("DIRC".utf8) + bytes += Self.bigEndian(UInt32(2)) + bytes += Self.bigEndian(UInt32(1)) + let entryStart = bytes.count + bytes += Self.bigEndian(UInt32(0)) + bytes += Self.bigEndian(UInt32(0)) + bytes += Self.bigEndian(UInt32(truncatingIfNeeded: fileStat.st_mtimespec.tv_sec)) + bytes += Self.bigEndian(UInt32(truncatingIfNeeded: fileStat.st_mtimespec.tv_nsec)) + bytes += Self.bigEndian(UInt32(0)) + bytes += Self.bigEndian(UInt32(0)) + bytes += Self.bigEndian(UInt32(0o100644)) + bytes += Self.bigEndian(UInt32(0)) + bytes += Self.bigEndian(UInt32(0)) + bytes += Self.bigEndian(UInt32(truncatingIfNeeded: fileStat.st_size)) + bytes += Array(repeating: UInt8(0xAA), count: 20) + let path = Array("file.txt".utf8) + bytes += Self.bigEndian(UInt16(path.count)) + bytes += path + bytes.append(0) + let padding = (8 - ((bytes.count - entryStart) % 8)) % 8 + bytes += Array(repeating: 0, count: padding) + bytes += Array(repeating: UInt8(0xAB), count: 20) + return Data(bytes) + } + + private static func bigEndian(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff), + ] + } + + private static func bigEndian(_ value: UInt16) -> [UInt8] { + [UInt8((value >> 8) & 0xff), UInt8(value & 0xff)] + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Engine/Rendering/GhosttyMetalLayer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Engine/Rendering/GhosttyMetalLayer.swift index ae9f67066692..1d10d935063f 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Engine/Rendering/GhosttyMetalLayer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Engine/Rendering/GhosttyMetalLayer.swift @@ -1,6 +1,16 @@ public import CmuxTerminalCore public import QuartzCore internal import Foundation +internal import OSLog + +@inline(__always) +func readRendererProfilingStateIfRequested( + _ requested: Bool, + _ read: () -> Value? +) -> Value? { + guard requested else { return nil } + return read() +} /// Lightweight instrumentation to detect whether Ghostty is actually requesting Metal drawables. /// This helps catch "frozen until refocus" regressions without relying on screenshots (which can @@ -13,12 +23,18 @@ internal import Foundation /// notifications hop to the main actor before touching the receiver. public final class GhosttyMetalLayer: CAMetalLayer { private let lock = NSLock() - // SAFETY: all four are guarded by `lock`; written/read from the renderer - // thread (`nextDrawable()`) and the main actor (configuration, debug HUD). + private let profilingSignposts = TerminalRendererProfilingSignposts() + // SAFETY: all mutable state below is guarded by `lock`; written/read from + // the renderer thread (`nextDrawable()`) and the main actor (configuration, + // debug HUD). nonisolated(unsafe) private var drawableCount: Int = 0 nonisolated(unsafe) private var lastDrawableTime: CFTimeInterval = 0 nonisolated(unsafe) private weak var frameReceiver: (any TerminalRenderedFrameReceiving)? nonisolated(unsafe) private var renderDemand: (any RenderDemandGating)? + nonisolated(unsafe) private var profilingIdentity: TerminalRendererProfilingIdentity? + nonisolated(unsafe) private var profilingVisible = true + nonisolated(unsafe) private var profilingFocused = false + nonisolated(unsafe) private var profilingWakeReason = TerminalRendererProfilingWakeReason.terminalOutput /// Injects the rendered-frame demand gate that decides whether vending a /// drawable should notify the receiver. @@ -35,6 +51,22 @@ public final class GhosttyMetalLayer: CAMetalLayer { lock.unlock() } + /// Updates typed renderer trace state without accepting terminal or process content. + public func setProfilingState( + identity: TerminalRendererProfilingIdentity?, + visible: Bool, + focused: Bool, + wakeReason: TerminalRendererProfilingWakeReason + ) { + guard profilingSignposts.collectionRequested else { return } + lock.lock() + profilingIdentity = identity + profilingVisible = visible + profilingFocused = focused + profilingWakeReason = wakeReason + lock.unlock() + } + /// The number of drawables vended so far and the media time of the last /// one, for debug HUDs. public func debugStats() -> (count: Int, last: CFTimeInterval) { @@ -44,17 +76,64 @@ public final class GhosttyMetalLayer: CAMetalLayer { } override public func nextDrawable() -> (any CAMetalDrawable)? { - guard let drawable = super.nextDrawable() else { return nil } - // One critical section for the instrumentation write and both - // injected-collaborator reads; the render thread takes this lock once - // per vended drawable. + let profilingEnabled = profilingSignposts.isEnabled + // Keep the ordinary render path identical to the legacy path: no lock + // before asking Metal for a drawable, then one critical section after + // success. Profiling pays for the additional state snapshot only while + // an opted-in signpost collector is active. + let profilingMetadata: TerminalRendererProfilingMetadata? = readRendererProfilingStateIfRequested( + profilingEnabled + ) { + lock.lock() + defer { lock.unlock() } + let metadata: TerminalRendererProfilingMetadata? = if let profilingIdentity { + TerminalRendererProfilingMetadata( + identity: profilingIdentity, + visible: profilingVisible, + focused: profilingFocused, + wakeReason: profilingWakeReason, + coalescedUpdateCount: 1, + dirtyRowCount: nil, + fullRedraw: nil + ) + } else { + nil + } + profilingWakeReason = .terminalOutput + return metadata + } + + let interval: OSSignpostIntervalState? = if let profilingMetadata { + profilingSignposts.beginFrame(profilingMetadata) + } else { + nil + } + guard let drawable = super.nextDrawable() else { + if let profilingMetadata { + profilingSignposts.endFrame(interval, profilingMetadata) + } + return nil + } + lock.lock() drawableCount += 1 lastDrawableTime = CACurrentMediaTime() let renderDemand = renderDemand let frameReceiver = frameReceiver lock.unlock() - guard renderDemand?.isActive == true else { return drawable } + + if let profilingMetadata, let interval { + drawable.addPresentedHandler { [profilingSignposts] _ in + profilingSignposts.endFrame(interval, profilingMetadata) + } + } + + let deliveryPolicy = TerminalRenderedFrameDeliveryPolicy( + renderDemandActive: renderDemand?.isActive == true + ) + guard deliveryPolicy.shouldEnqueue(profilingEnabled: profilingEnabled) else { + return drawable + } if let frameReceiver { // Hop to the main actor exactly like the legacy // DispatchQueue.main.async dispatch (the main-actor executor is diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift index 379e0c8a15f2..455319660613 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator.swift @@ -41,14 +41,20 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { /// main-thread owner state. /// - callbackContext: The retained callback context released on the /// main actor after the free completes. + /// - manualIOContext: Retained MANUAL-mode write callback userdata, + /// released only after native free has stopped the I/O thread. + /// - byteTeeLease: The retained tee callback userdata released only + /// after the native free has stopped the PTY read thread. /// - freeSurface: The free operation; defaults to /// `ghostty_surface_free`. - public nonisolated func enqueueRuntimeTeardown( + nonisolated func enqueueRuntimeTeardown( id: UUID, workspaceId: UUID, reason: String, surface: ghostty_surface_t, callbackContext: Unmanaged?, + manualIOContext: Unmanaged? = nil, + byteTeeLease: (any TerminalByteTeeLease)? = nil, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void = { surface in ghostty_surface_free(surface) } @@ -59,6 +65,8 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { reason: reason, surface: surface, callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: byteTeeLease, freeSurface: freeSurface ) Task { @@ -99,12 +107,15 @@ public actor TerminalSurfaceRuntimeTeardownCoordinator { ) #endif request.freeSurface(request.surface) - if request.callbackContext != nil { + if request.callbackContext != nil || request.manualIOContext != nil || request.byteTeeLease != nil { // The request is the @unchecked Sendable transport for the - // Unmanaged context; release through the request so the @Sendable - // closure never captures the non-Sendable Unmanaged directly. + // callback userdata; release through the request so the @Sendable + // closure never captures either owner directly. Waiting until + // native free returns guarantees the PTY read callback is gone. await MainActor.run { request.callbackContext?.release() + request.manualIOContext?.release() + request.byteTeeLease?.release() } } #if DEBUG diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift index 353e54f3a92d..1969f8a6f701 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownRequest.swift @@ -7,14 +7,16 @@ public import CmuxTerminalCore /// The native pointer has been removed from all main-thread owner state /// before this request is created; this wrapper only transports the one-shot /// free. It is `@unchecked Sendable` for exactly that reason: the surface -/// pointer and `Unmanaged` callback context are exclusively owned by the -/// request from creation until the coordinator consumes them. +/// pointer, callback context, and terminal-byte-tee lease are exclusively +/// owned by the request from creation until the coordinator consumes them. struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { let id: UUID let workspaceId: UUID let reason: String let surface: ghostty_surface_t let callbackContext: Unmanaged? + let manualIOContext: Unmanaged? + let byteTeeLease: (any TerminalByteTeeLease)? let freeSurface: @Sendable (ghostty_surface_t) -> Void #if DEBUG let surfaceToken: String @@ -27,6 +29,8 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { reason: String, surface: ghostty_surface_t, callbackContext: Unmanaged?, + manualIOContext: Unmanaged?, + byteTeeLease: (any TerminalByteTeeLease)?, freeSurface: @escaping @Sendable (ghostty_surface_t) -> Void ) { self.id = id @@ -34,6 +38,8 @@ struct TerminalSurfaceRuntimeTeardownRequest: @unchecked Sendable { self.reason = reason self.surface = surface self.callbackContext = callbackContext + self.manualIOContext = manualIOContext + self.byteTeeLease = byteTeeLease self.freeSurface = freeSurface #if DEBUG self.surfaceToken = String(id.uuidString.prefix(5)) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalScrollbackBudget.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalScrollbackBudget.swift new file mode 100644 index 000000000000..4130812f6224 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalScrollbackBudget.swift @@ -0,0 +1,30 @@ +/// Sets a logical per-surface scrollback cap for cmux's supported concurrency scale. +/// +/// Ghostty's page allocation limit is heuristic. Active content, page rounding, +/// and complex graphemes can exceed the configured cap. The fixed per-surface +/// cap also scales linearly beyond `supportedSurfaceCount`. +struct TerminalScrollbackBudget: Equatable, Sendable { + let targetAggregateScrollbackBytesAtSupportedScale: Int + let supportedSurfaceCount: Int + + /// Keeps 64 saturated surfaces to 512 MiB and 128 surfaces to 1 GiB. + static let cmuxDefault = TerminalScrollbackBudget( + targetAggregateScrollbackBytesAtSupportedScale: 1_024 * 1_048_576, + supportedSurfaceCount: 128 + ) + + init(targetAggregateScrollbackBytesAtSupportedScale: Int, supportedSurfaceCount: Int) { + precondition(targetAggregateScrollbackBytesAtSupportedScale > 0) + precondition(supportedSurfaceCount > 0) + self.targetAggregateScrollbackBytesAtSupportedScale = targetAggregateScrollbackBytesAtSupportedScale + self.supportedSurfaceCount = supportedSurfaceCount + } + + var maxBytesPerSurface: Int { + targetAggregateScrollbackBytesAtSupportedScale / supportedSurfaceCount + } + + func configuredCapBytes(surfaceCount: Int) -> Int { + max(surfaceCount, 0) * maxBytesPerSurface + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift index ebc3f7d4176d..effe85b32892 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualIOWrite.swift @@ -8,9 +8,9 @@ import Foundation /// with `ghostty_surface_process_output`. cmux uses this for remote-tmux pane /// display surfaces. final class TerminalManualIOWriteBox { - /// Invoked with bytes the user typed into the surface. Runs on ghostty's - /// I/O thread, so the closure must be Sendable and hop to the main actor - /// itself before touching MainActor state. + /// Invoked inline by whichever thread sends input into the manual Ghostty + /// backend, so the closure must be Sendable and hop to the main actor + /// before touching MainActor state. let onWrite: @Sendable (Data) -> Void init(onWrite: @escaping @Sendable (Data) -> Void) { diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererEventCallback.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererEventCallback.swift new file mode 100644 index 000000000000..8c8c23c82b1b --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererEventCallback.swift @@ -0,0 +1,13 @@ +internal import CmuxTerminalCore +internal import GhosttyKit + +let terminalRendererEventCallback: @convention(c) ( + UnsafeMutableRawPointer?, + ghostty_renderer_event_e +) -> Void = { userdata, event in + guard let userdata else { return } + let context = Unmanaged + .fromOpaque(userdata) + .takeUnretainedValue() + context.recordRendererEvent(event) +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift index d3154048d851..6d5b22cbfaa9 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift @@ -181,9 +181,16 @@ extension TerminalSurface { public func releaseSurfaceForTesting() { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil + let manualIOContext = manualIOContext + self.manualIOContext = nil + let teeLease = mobileByteTeeLease + mobileByteTeeLease = nil + byteTee.dropSurface(surfaceID: id) guard let surfaceToFree = surface else { callbackContext?.release() + manualIOContext?.release() + teeLease?.release() return } @@ -191,6 +198,8 @@ extension TerminalSurface { surface = nil ghostty_surface_free(surfaceToFree) callbackContext?.release() + manualIOContext?.release() + teeLease?.release() } /// Test-only helper to simulate a stale Swift wrapper whose native surface @@ -201,9 +210,16 @@ extension TerminalSurface { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil + let manualIOContext = manualIOContext + self.manualIOContext = nil + let teeLease = mobileByteTeeLease + mobileByteTeeLease = nil + byteTee.dropSurface(surfaceID: id) guard let surfaceToFree = surface else { callbackContext?.release() + manualIOContext?.release() + teeLease?.release() return } @@ -211,6 +227,8 @@ extension TerminalSurface { ghostty_surface_free(surfaceToFree) runtimeSurfaceFreedOutOfBandForTesting = true callbackContext?.release() + manualIOContext?.release() + teeLease?.release() } /// Test-only helper to install a runtime surface pointer directly. @@ -219,6 +237,7 @@ extension TerminalSurface { surface = runtimeSurface portalLifecycleState = .live runtimeSurfaceFreedOutOfBandForTesting = false + applyRetainedOcclusionAfterRuntimeInstallation() } #endif } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index d479f70b505e..a51642365850 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -16,6 +16,7 @@ extension TerminalSurface { /// Without this, `createSurface` would replay a stale state on recreation. public func recordExternalFocusState(_ focused: Bool) { desiredFocusState = focused + updateRendererCallbackProfilingState() } /// Applies a focus state to the runtime surface (deduplicated). @@ -25,6 +26,7 @@ extension TerminalSurface { // prompt redraws with zsh themes like Powerlevel10k. guard force || focused != desiredFocusState else { return } desiredFocusState = focused + updateRendererCallbackProfilingState() // Track desired state even before the C surface exists (e.g. during // layout restoration). createSurface syncs the state once created. guard let surface = surface else { return } @@ -43,10 +45,44 @@ extension TerminalSurface { } } - /// Applies the occlusion state to the runtime surface. + /// Records and applies the desired occlusion state. + /// + /// The desired value survives periods without a native surface. This is + /// important for hidden restored and hibernated panes: their portal often + /// transitions to hidden before creation and does not emit a duplicate + /// transition after recreation. public func setOcclusion(_ visible: Bool) { + desiredOcclusionVisible = visible + updateRendererCallbackProfilingState() + applyDesiredOcclusionIfNeeded() + } + + private func updateRendererCallbackProfilingState() { + surfaceCallbackContext?.takeUnretainedValue().updateRendererProfilingState( + visible: desiredOcclusionVisible, + focused: desiredFocusState + ) + } + + /// Applies retained visibility to the current native surface once. + /// Called after creation and by visibility transitions. + public func applyDesiredOcclusionIfNeeded() { guard let surface = surface else { return } - ghostty_surface_set_occlusion(surface, visible) + guard lastAppliedOcclusionVisible != desiredOcclusionVisible else { return } + ghostty_surface_set_occlusion(surface, desiredOcclusionVisible) + lastAppliedOcclusionVisible = desiredOcclusionVisible + } + + /// Starts deduplication afresh when a native surface is replaced. + func noteRuntimeSurfaceRecreatedForOcclusion() { + lastAppliedOcclusionVisible = nil + } + + /// Finalizes one native-surface installation at the lifecycle boundary + /// where display, scale, size, and focus have already converged. + func applyRetainedOcclusionAfterRuntimeInstallation() { + noteRuntimeSurfaceRecreatedForOcclusion() + applyDesiredOcclusionIfNeeded() } /// Whether this surface currently holds realized GPU renderer resources. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift index 24dac644fdff..7822e3d454fc 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift @@ -130,6 +130,8 @@ extension TerminalSurface { GhosttySurfaceRuntimeProbe.surfacePointerAppearsLive(surface) else { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil + let manualIOContext = manualIOContext + self.manualIOContext = nil let teeLease = mobileByteTeeLease mobileByteTeeLease = nil registry.unregisterRuntimeSurface(surface, ownerId: id) @@ -146,6 +148,7 @@ extension TerminalSurface { ) #endif callbackContext?.release() + manualIOContext?.release() teeLease?.release() return nil } @@ -269,12 +272,11 @@ extension TerminalSurface { reason: "teardown", surface: surfaceToFree, callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease, freeSurface: freeSurface ) - // The teardown coordinator releases callbackContext; manualIOContext - // and teeLease are not transported through the request, so release them here. - manualIOContext?.release() - teeLease?.release() + // The coordinator releases every callback owner after free returns. return } #endif @@ -315,6 +317,7 @@ extension TerminalSurface { pendingSocketInputQueue.removeAll(keepingCapacity: false) pendingSocketInputBytes = 0 desiredFocusState = false + noteRuntimeSurfaceRecreatedForOcclusion() guard let surfaceToFree else { callbackContext?.release() @@ -338,12 +341,11 @@ extension TerminalSurface { reason: reason, surface: surfaceToFree, callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease, freeSurface: freeSurface ) - // The teardown coordinator releases callbackContext; manualIOContext - // and teeLease are not transported through the request, so release them here. - manualIOContext?.release() - teeLease?.release() + // The coordinator releases every callback owner after free returns. return } #endif @@ -622,6 +624,7 @@ extension TerminalSurface { // surface converges with any focus changes that happened while the // surface was being initialized. ghostty_surface_set_focus(createdSurface, desiredFocusState) + applyRetainedOcclusionAfterRuntimeInstallation() flushPendingSocketInputIfNeeded() diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index 09c04f03d194..6620507f68d8 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -30,8 +30,16 @@ extension TerminalSurface { surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view as NSView).toOpaque() )) - let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceHost: view, surfaceController: self)) + let context = GhosttySurfaceCallbackContext(surfaceHost: view, surfaceController: self) + context.updateRendererProfilingState( + visible: desiredOcclusionVisible, + focused: desiredFocusState + ) + let callbackContext = Unmanaged.passRetained(context) surfaceConfig.userdata = callbackContext.toOpaque() + if context.rendererEventProfilingRequested { + surfaceConfig.renderer_event_cb = terminalRendererEventCallback + } surfaceCallbackContext?.release() surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer @@ -281,14 +289,22 @@ extension TerminalSurface { envVars: inout [ghostty_env_var_s] ) -> ghostty_surface_t? { if envVars.isEmpty { - return ghostty_surface_new(app, &surfaceConfig) + return GhosttyRuntimeCInterop.createSurface( + app: app, + config: &surfaceConfig, + scrollbackLimitBytes: TerminalScrollbackBudget.cmuxDefault.maxBytesPerSurface + ) } let envVarsCount = envVars.count return envVars.withUnsafeMutableBufferPointer { buffer in surfaceConfig.env_vars = buffer.baseAddress surfaceConfig.env_var_count = envVarsCount - return ghostty_surface_new(app, &surfaceConfig) + return GhosttyRuntimeCInterop.createSurface( + app: app, + config: &surfaceConfig, + scrollbackLimitBytes: TerminalScrollbackBudget.cmuxDefault.maxBytesPerSurface + ) } } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 7a68d2a6da2a..7276ddb09437 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -136,6 +136,11 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// The owning workspace id. public private(set) var tabId: UUID + /// Stable, opaque identifiers used by renderer profiling across runtime recreation. + public var rendererProfilingIdentity: TerminalRendererProfilingIdentity { + TerminalRendererProfilingIdentity(workspaceId: tabId, surfaceId: id) + } + /// Port ordinal for CMUX_PORT range assignment. Captured at construction so /// every runtime startup path uses the same immutable workspace port range. let portOrdinal: Int @@ -258,6 +263,13 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// path explicitly requests it so background panes do not keep a focused /// state unless the workspace focus path requests it. var desiredFocusState: Bool = false + /// Desired Ghostty renderer visibility, retained even while the native + /// surface is absent so a hidden pane cannot restart visible after runtime + /// recreation. + var desiredOcclusionVisible = true + /// Last visibility applied to the current native surface. Reset whenever + /// that native surface is replaced. + var lastAppliedOcclusionVisible: Bool? /// Bumped after every completed runtime clipboard read. public internal(set) var clipboardReadGeneration = 0 @@ -574,12 +586,12 @@ public final class TerminalSurface: Identifiable, ObservableObject { workspaceId: tabId, reason: "deinit", surface: surfaceToFree, - callbackContext: callbackContext + callbackContext: callbackContext, + manualIOContext: manualIOContext, + byteTeeLease: teeLease ) - // The teardown coordinator releases callbackContext; manualIOContext and - // teeLease are not transported through the request, so release them here. - manualIOContext?.release() - teeLease?.release() + // The teardown coordinator releases every callback owner after native + // free has stopped the PTY/manual-I/O thread. } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyMetalLayerProfilingTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyMetalLayerProfilingTests.swift new file mode 100644 index 000000000000..23cb7c69bed2 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/GhosttyMetalLayerProfilingTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import CmuxTerminal + +@Suite +struct GhosttyMetalLayerProfilingTests { + @Test + func disabledProfilingSkipsPreDrawableStateReadAndReset() { + var stateReadCount = 0 + var wakeReasonResetCount = 0 + + let snapshot: Int? = readRendererProfilingStateIfRequested(false) { + stateReadCount += 1 + wakeReasonResetCount += 1 + return 42 + } + + #expect(snapshot == nil) + #expect(stateReadCount == 0) + #expect(wakeReasonResetCount == 0) + } + + @Test + func enabledProfilingReadsAndResetsStateOnce() { + var stateReadCount = 0 + var wakeReasonResetCount = 0 + + let snapshot: Int? = readRendererProfilingStateIfRequested(true) { + stateReadCount += 1 + wakeReasonResetCount += 1 + return 42 + } + + #expect(snapshot == 42) + #expect(stateReadCount == 1) + #expect(wakeReasonResetCount == 1) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalScrollbackBudgetTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalScrollbackBudgetTests.swift new file mode 100644 index 000000000000..f7186481374e --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalScrollbackBudgetTests.swift @@ -0,0 +1,56 @@ +import GhosttyKit +import GhosttyRuntimeTestStubs +import Testing + +@testable import CmuxTerminal + +@Suite("Terminal scrollback budget") +struct TerminalScrollbackBudgetTests { + @Test func defaultBudgetCapsSixtyFourSurfacesAtFiveHundredTwelveMiB() { + let budget = TerminalScrollbackBudget.cmuxDefault + + #expect(budget.maxBytesPerSurface == 8 * 1_048_576) + #expect(budget.configuredCapBytes(surfaceCount: 64) == 512 * 1_048_576) + #expect(budget.configuredCapBytes(surfaceCount: 128) == 1_024 * 1_048_576) + } + + @Test func configuredCapScalesLinearlyBeyondSupportedSurfaceCount() { + let budget = TerminalScrollbackBudget( + targetAggregateScrollbackBytesAtSupportedScale: 100, + supportedSurfaceCount: 4 + ) + + #expect(budget.maxBytesPerSurface == 25) + #expect(budget.configuredCapBytes(surfaceCount: 0) == 0) + #expect(budget.configuredCapBytes(surfaceCount: 6) == 150) + } + + @Test func runtimeConstructorPassesCapWithoutGrowingPublicConfig() throws { + #expect(MemoryLayout.size == 120) + cmux_test_ghostty_runtime_stubs_reset() + + let configStorage = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment + ) + defer { configStorage.deallocate() } + configStorage.initializeMemory( + as: UInt8.self, + repeating: 0, + count: MemoryLayout.size + ) + let config = configStorage.assumingMemoryBound(to: ghostty_surface_config_s.self) + let app = try #require(ghostty_app_t(bitPattern: 0x1)) + + _ = GhosttyRuntimeCInterop.createSurface( + app: app, + config: UnsafePointer(config), + scrollbackLimitBytes: TerminalScrollbackBudget.cmuxDefault.maxBytesPerSurface + ) + + #expect( + cmux_test_ghostty_surface_last_scrollback_limit_bytes() + == UInt(TerminalScrollbackBudget.cmuxDefault.maxBytesPerSurface) + ) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceOcclusionTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceOcclusionTests.swift new file mode 100644 index 000000000000..5d10b10136c9 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceOcclusionTests.swift @@ -0,0 +1,75 @@ +import Foundation +import GhosttyKit +import GhosttyRuntimeTestStubs +import Testing +@testable import CmuxTerminal + +@Suite(.serialized) @MainActor +struct TerminalSurfaceOcclusionTests { + init() { + cmux_test_ghostty_runtime_stubs_reset() + } + + @Test func hiddenIntentBeforeCreationIsAppliedWhenRuntimeAppears() throws { + let surface = makeSurface() + + surface.setOcclusion(false) + #expect(cmux_test_ghostty_surface_set_occlusion_call_count() == 0) + + surface.installRuntimeSurfaceForTesting( + try #require(ghostty_surface_t(bitPattern: 0x1)) + ) + + #expect(cmux_test_ghostty_surface_set_occlusion_call_count() == 1) + #expect(!cmux_test_ghostty_surface_last_occlusion_visible()) + } + + @Test func hiddenIntentSurvivesRuntimeRecreationAndDeduplicatesWithinOneRuntime() throws { + let surface = makeSurface() + surface.setOcclusion(false) + + surface.installRuntimeSurfaceForTesting( + try #require(ghostty_surface_t(bitPattern: 0x1)) + ) + surface.setOcclusion(false) + #expect(cmux_test_ghostty_surface_set_occlusion_call_count() == 1) + + surface.installRuntimeSurfaceForTesting( + try #require(ghostty_surface_t(bitPattern: 0x2)) + ) + + #expect(cmux_test_ghostty_surface_set_occlusion_call_count() == 2) + #expect(!cmux_test_ghostty_surface_last_occlusion_visible()) + } + + private func makeSurface() -> TerminalSurface { + let nativeView = FakeTerminalSurfaceNativeView() + return TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + dependencies: TerminalSurfaceRuntimeDependencies( + registry: FakeSurfaceRegistry(), + engine: FakeTerminalEngine(), + viewProvider: FakeTerminalSurfaceViewProvider( + surfaceView: nativeView, + paneHost: FakeTerminalSurfacePaneHost(surfaceView: nativeView) + ), + spawnPolicy: FakeSpawnPolicyProvider(), + byteTee: FakeTerminalByteTee(), + rendererRealization: FakeRendererRealizationScheduler(), + hibernationRecorder: FakeHibernationRecorder(), + runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator(), + restoreSpawnScheduler: RecordingRestoreSpawnScheduler(), + runtimeFilesystem: TerminalSurfaceRuntimeFilesystem( + claudeCommandShimTemporaryDirectory: URL(fileURLWithPath: "/tmp/cmux-terminal-tests"), + installClaudeCommandShim: { _, _, _ in nil }, + isExecutableFile: { _ in false } + ), + sessionPortBase: 40_000, + sessionPortRangeSize: 100, + scrollbackReplayEnvironmentKey: "CMUX_TEST_SCROLLBACK_REPLAY" + ) + ) + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift index 686c422f625f..063e2b28985d 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRuntimeTeardownCoordinatorTests.swift @@ -1,4 +1,5 @@ import Foundation +import os import Testing @testable import CmuxTerminal @@ -26,6 +27,37 @@ private actor FreedSurfaceRecorder { } } +private final class TeardownLifetimeRecorder: @unchecked Sendable { + let events: AsyncStream + private let continuation: AsyncStream.Continuation + private let recordedEvents = OSAllocatedUnfairLock(initialState: [String]()) + + init() { + (events, continuation) = AsyncStream.makeStream(of: String.self) + } + + func record(_ event: String) { + recordedEvents.withLock { $0.append(event) } + continuation.yield(event) + } + + func snapshot() -> [String] { + recordedEvents.withLock { $0 } + } +} + +private final class RecordingTerminalByteTeeLease: TerminalByteTeeLease, @unchecked Sendable { + private let recorder: TeardownLifetimeRecorder + + init(recorder: TeardownLifetimeRecorder) { + self.recorder = recorder + } + + func release() { + recorder.record("tee.release") + } +} + @Suite struct TerminalSurfaceRuntimeTeardownCoordinatorTests { @Test func enqueuedTeardownInvokesInjectedFreeWithTheSamePointer() async { let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() @@ -74,4 +106,30 @@ private actor FreedSurfaceRecorder { await recorder.waitForFreeCount(surfaces.count) #expect(await Set(recorder.freed) == Set(surfaces.map { UInt(bitPattern: $0) })) } + + @Test func byteTeeCallbackOwnerIsReleasedOnlyAfterNativeFreeReturns() async { + let coordinator = TerminalSurfaceRuntimeTeardownCoordinator() + let recorder = TeardownLifetimeRecorder() + let lease = RecordingTerminalByteTeeLease(recorder: recorder) + let surface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { surface.deallocate() } + + coordinator.enqueueRuntimeTeardown( + id: UUID(), + workspaceId: UUID(), + reason: "test.teeLifetime", + surface: surface, + callbackContext: nil, + manualIOContext: nil, + byteTeeLease: lease, + freeSurface: { _ in + recorder.record("surface.free") + } + ) + + for await event in recorder.events where event == "tee.release" { + break + } + #expect(recorder.snapshot() == ["surface.free", "tee.release"]) + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index d524aba01f8d..8994380302ff 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -4,11 +4,17 @@ static bool cmux_test_needs_confirm_quit = false; static uint64_t cmux_test_foreground_pid = 0; static const char* cmux_test_tty_name = NULL; +static uint64_t cmux_test_occlusion_call_count = 0; +static bool cmux_test_last_occlusion_visible = false; +static uintptr_t cmux_test_last_scrollback_limit_bytes = 0; void cmux_test_ghostty_runtime_stubs_reset(void) { cmux_test_needs_confirm_quit = false; cmux_test_foreground_pid = 0; cmux_test_tty_name = NULL; + cmux_test_occlusion_call_count = 0; + cmux_test_last_occlusion_visible = false; + cmux_test_last_scrollback_limit_bytes = 0; } void cmux_test_ghostty_runtime_stubs_set_close_state(bool needs_confirm, uint64_t foreground_pid, const char* tty_name) { @@ -45,6 +51,12 @@ bool ghostty_surface_needs_confirm_quit(void *surface) { return cmux_test_needs_confirm_quit; } void ghostty_surface_new(void) {} +void* ghostty_surface_new_with_scrollback_limit(void* app, const void* config, uintptr_t scrollback_limit_bytes) { + (void)app; + (void)config; + cmux_test_last_scrollback_limit_bytes = scrollback_limit_bytes; + return NULL; +} bool ghostty_surface_process_exited(void *surface) { (void)surface; return false; @@ -58,7 +70,11 @@ void ghostty_surface_render_grid_json(void) {} void ghostty_surface_set_content_scale(void) {} void ghostty_surface_set_display_id(void) {} void ghostty_surface_set_focus(void) {} -void ghostty_surface_set_occlusion(void) {} +void ghostty_surface_set_occlusion(void *surface, bool visible) { + (void)surface; + cmux_test_occlusion_call_count += 1; + cmux_test_last_occlusion_visible = visible; +} void ghostty_surface_set_renderer_realized(void) {} void ghostty_surface_set_size(void) {} void ghostty_surface_size(void) {} @@ -71,3 +87,15 @@ ghostty_string_s ghostty_surface_tty_name(void *surface) { } return (ghostty_string_s){.ptr = cmux_test_tty_name, .len = strlen(cmux_test_tty_name), .sentinel = false}; } + +uint64_t cmux_test_ghostty_surface_set_occlusion_call_count(void) { + return cmux_test_occlusion_call_count; +} + +bool cmux_test_ghostty_surface_last_occlusion_visible(void) { + return cmux_test_last_occlusion_visible; +} + +uintptr_t cmux_test_ghostty_surface_last_scrollback_limit_bytes(void) { + return cmux_test_last_scrollback_limit_bytes; +} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 1021fd419f7e..b414c3e73246 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -32,6 +32,7 @@ void ghostty_surface_mouse_pos(void); void ghostty_surface_mouse_scroll(void); bool ghostty_surface_needs_confirm_quit(void *surface); void ghostty_surface_new(void); +void* ghostty_surface_new_with_scrollback_limit(void* app, const void* config, uintptr_t scrollback_limit_bytes); bool ghostty_surface_process_exited(void *surface); void ghostty_surface_process_output(void); void ghostty_surface_quicklook_font(void); @@ -42,7 +43,7 @@ void ghostty_surface_render_grid_json(void); void ghostty_surface_set_content_scale(void); void ghostty_surface_set_display_id(void); void ghostty_surface_set_focus(void); -void ghostty_surface_set_occlusion(void); +void ghostty_surface_set_occlusion(void *surface, bool visible); void ghostty_surface_set_renderer_realized(void); void ghostty_surface_set_size(void); void ghostty_surface_size(void); @@ -52,5 +53,8 @@ ghostty_string_s ghostty_surface_tty_name(void *surface); void cmux_test_ghostty_runtime_stubs_reset(void); void cmux_test_ghostty_runtime_stubs_set_close_state(bool needs_confirm, uint64_t foreground_pid, const char* tty_name); +uint64_t cmux_test_ghostty_surface_set_occlusion_call_count(void); +bool cmux_test_ghostty_surface_last_occlusion_visible(void); +uintptr_t cmux_test_ghostty_surface_last_scrollback_limit_bytes(void); #endif diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift index 448c4aeba50e..6368af9e2e6d 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift @@ -19,6 +19,25 @@ private func cmux_ghostty_surface_clear_selection(_ surface: ghostty_surface_t) public struct GhosttyRuntimeCInterop { private init() {} + /// Creates a runtime surface with a separate scrollback upper bound. + /// + /// The limit stays outside ``ghostty_surface_config_s`` so adding this cmux + /// extension does not change that public C structure's byte layout. + /// + /// - Parameters: + /// - app: The live Ghostty application that will own the surface. + /// - config: The standard Ghostty surface configuration. + /// - scrollbackLimitBytes: The per-surface upper bound, or zero to inherit + /// the configured `scrollback-limit`. + /// - Returns: The created surface, or `nil` when Ghostty initialization fails. + public static func createSurface( + app: ghostty_app_t, + config: UnsafePointer, + scrollbackLimitBytes: Int + ) -> ghostty_surface_t? { + ghostty_surface_new_with_scrollback_limit(app, config, scrollbackLimitBytes) + } + /// Clears the active selection on a runtime surface. /// /// Mirrors `ghostty_surface_clear_selection` from the cmux libghostty diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingEvent.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingEvent.swift new file mode 100644 index 000000000000..dddb7ec0eb99 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingEvent.swift @@ -0,0 +1,94 @@ +public import Foundation +public import GhosttyKit + +/// Content-free renderer activity emitted by Ghostty's renderer thread. +public enum TerminalRendererProfilingEvent: String, Sendable { + case updateFrameBegin = "update-frame-begin" + case updateFrameEnd = "update-frame-end" + case drawFrameBegin = "draw-frame-begin" + case drawFrameEnd = "draw-frame-end" + + public init?(_ event: ghostty_renderer_event_e) { + switch event { + case GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_BEGIN: self = .updateFrameBegin + case GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END: self = .updateFrameEnd + case GHOSTTY_RENDERER_EVENT_DRAW_FRAME_BEGIN: self = .drawFrameBegin + case GHOSTTY_RENDERER_EVENT_DRAW_FRAME_END: self = .drawFrameEnd + default: return nil + } + } + + var interval: TerminalRendererEventInterval { + switch self { + case .updateFrameBegin, .updateFrameEnd: .updateFrame + case .drawFrameBegin, .drawFrameEnd: .drawFrame + } + } + + var isBegin: Bool { + switch self { + case .updateFrameBegin, .drawFrameBegin: true + case .updateFrameEnd, .drawFrameEnd: false + } + } +} + +enum TerminalRendererEventInterval: Equatable, Hashable, Sendable { + case updateFrame + case drawFrame +} + +enum TerminalRendererEventPairingAction: Equatable, Sendable { + case begin(TerminalRendererEventInterval) + case end(TerminalRendererEventInterval) +} + +struct TerminalRendererEventPairing: Sendable { + private var active: Set = [] + + mutating func consume(_ event: TerminalRendererProfilingEvent) -> TerminalRendererEventPairingAction? { + let interval = event.interval + if event.isBegin { + guard active.insert(interval).inserted else { return nil } + return .begin(interval) + } + guard active.remove(interval) != nil else { return nil } + return .end(interval) + } +} + +/// Privacy-closed payload for one exact Ghostty renderer event. +public struct TerminalRendererEventProfilingMetadata: Equatable, Sendable { + public let identity: TerminalRendererProfilingIdentity + public let visible: Bool + public let focused: Bool + public let event: TerminalRendererProfilingEvent + + public init( + identity: TerminalRendererProfilingIdentity, + visible: Bool, + focused: Bool, + event: TerminalRendererProfilingEvent + ) { + self.identity = identity + self.visible = visible + self.focused = focused + self.event = event + } + + public var details: String { + var details = String() + details.reserveCapacity(160) + details.append("workspace=") + details.append(identity.workspaceId.uuidString) + details.append(" surface=") + details.append(identity.surfaceId.uuidString) + details.append(" visible=") + details.append(visible ? "1" : "0") + details.append(" focused=") + details.append(focused ? "1" : "0") + details.append(" event=") + details.append(event.rawValue) + return details + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingIdentity.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingIdentity.swift new file mode 100644 index 000000000000..b04f0a739922 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingIdentity.swift @@ -0,0 +1,16 @@ +public import Foundation + +/// Stable, privacy-safe identifiers used to correlate one terminal renderer across lifecycle changes. +public struct TerminalRendererProfilingIdentity: Equatable, Sendable { + /// The workspace that owns the terminal surface. + public let workspaceId: UUID + + /// The stable terminal-surface identifier. + public let surfaceId: UUID + + /// Creates a renderer identity from cmux-owned opaque identifiers. + public init(workspaceId: UUID, surfaceId: UUID) { + self.workspaceId = workspaceId + self.surfaceId = surfaceId + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingMetadata.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingMetadata.swift new file mode 100644 index 000000000000..7c45ae32946d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingMetadata.swift @@ -0,0 +1,63 @@ +import Foundation + +/// A closed set of renderer state that is safe to publish in profiling traces. +public struct TerminalRendererProfilingMetadata: Equatable, Sendable { + /// Opaque cmux workspace and surface identifiers. + public let identity: TerminalRendererProfilingIdentity + /// Whether cmux considers the surface visible. + public let visible: Bool + /// Whether cmux considers the surface focused. + public let focused: Bool + /// The typed source that most recently requested renderer work. + public let wakeReason: TerminalRendererProfilingWakeReason + /// The number of renderer notifications combined into this update. + public let coalescedUpdateCount: Int + /// Exact dirty rows when the renderer exposes them, otherwise `nil`. + public let dirtyRowCount: Int? + /// Exact full-redraw state when the renderer exposes it, otherwise `nil`. + public let fullRedraw: Bool? + + /// Creates a privacy-closed renderer profiling payload. + public init( + identity: TerminalRendererProfilingIdentity, + visible: Bool, + focused: Bool, + wakeReason: TerminalRendererProfilingWakeReason, + coalescedUpdateCount: Int, + dirtyRowCount: Int?, + fullRedraw: Bool? + ) { + self.identity = identity + self.visible = visible + self.focused = focused + self.wakeReason = wakeReason + self.coalescedUpdateCount = coalescedUpdateCount + self.dirtyRowCount = dirtyRowCount + self.fullRedraw = fullRedraw + } + + /// The public trace payload. No API input can contain terminal text, commands, paths, or environment values. + public var details: String { + let dirtyRows = dirtyRowCount.map(String.init) ?? "unknown" + let redraw = fullRedraw.map { $0 ? "1" : "0" } ?? "unknown" + var details = String() + details.reserveCapacity(200) + details.append("workspace=") + details.append(identity.workspaceId.uuidString) + details.append(" surface=") + details.append(identity.surfaceId.uuidString) + details.append(" visible=") + details.append(visible ? "1" : "0") + details.append(" focused=") + details.append(focused ? "1" : "0") + details.append(" wake=") + details.append(wakeReason.rawValue) + details.append(" coalesced=") + details.append(String(coalescedUpdateCount)) + details.append(" dirty_rows=") + details.append(dirtyRows) + details.append(" full_redraw=") + details.append(redraw) + return details + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingSignposts.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingSignposts.swift new file mode 100644 index 000000000000..1a04a5efac12 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingSignposts.swift @@ -0,0 +1,131 @@ +internal import Foundation +public import OSLog + +/// Emits terminal-renderer points of interest with negligible disabled-path work. +public struct TerminalRendererProfilingSignposts: Sendable { + private let signposter: OSSignposter + public let collectionRequested: Bool + + private static let defaultCollectionRequested: Bool = { + let environment = ProcessInfo.processInfo.environment + return environment["CMUX_TERMINAL_RENDERER_PROFILING"] == "1" || + environment["CMUX_KEY_LATENCY_PROBE"] == "1" + }() + + /// Creates a points-of-interest signpost emitter captured by standard Instruments templates. + /// Collection is opt-in through `CMUX_TERMINAL_RENDERER_PROFILING=1` or the existing + /// `CMUX_KEY_LATENCY_PROBE=1` profiling mode so ordinary rendering retains its disabled path. + public init() { + self.init( + signposter: OSSignposter( + subsystem: "com.cmux.terminal-renderer", + category: .pointsOfInterest + ), + collectionRequested: Self.defaultCollectionRequested + ) + } + + init(signposter: OSSignposter, collectionRequested: Bool) { + self.signposter = signposter + self.collectionRequested = collectionRequested + } + + /// Whether a trace collector currently records points-of-interest signposts. + @inline(__always) + public var isEnabled: Bool { collectionRequested && signposter.isEnabled } + + @inline(__always) + public func beginRendererEvent( + _ metadata: @autoclosure () -> TerminalRendererEventProfilingMetadata + ) -> OSSignpostIntervalState? { + guard isEnabled else { return nil } + let metadata = metadata() + switch metadata.event.interval { + case .updateFrame: + return signposter.beginInterval( + "terminal-renderer-update-frame", + id: signposter.makeSignpostID(), + "\(metadata.details, privacy: .public)" + ) + case .drawFrame: + return signposter.beginInterval( + "terminal-renderer-draw-frame", + id: signposter.makeSignpostID(), + "\(metadata.details, privacy: .public)" + ) + } + } + + @inline(__always) + public func endRendererEvent( + _ state: OSSignpostIntervalState?, + _ metadata: @autoclosure () -> TerminalRendererEventProfilingMetadata + ) { + guard let state else { return } + let metadata = metadata() + switch metadata.event.interval { + case .updateFrame: + signposter.endInterval( + "terminal-renderer-update-frame", + state, + "\(metadata.details, privacy: .public)" + ) + case .drawFrame: + signposter.endInterval( + "terminal-renderer-draw-frame", + state, + "\(metadata.details, privacy: .public)" + ) + } + } + + /// Starts one drawable-to-presentation frame interval. + @inline(__always) + public func beginFrame( + _ metadata: @autoclosure () -> TerminalRendererProfilingMetadata + ) -> OSSignpostIntervalState? { + guard isEnabled else { return nil } + let details = metadata().details + return signposter.beginInterval( + "terminal-renderer-frame", + id: signposter.makeSignpostID(), + "\(details, privacy: .public)" + ) + } + + /// Ends a frame interval. + @inline(__always) + public func endFrame( + _ state: OSSignpostIntervalState?, + _ metadata: @autoclosure () -> TerminalRendererProfilingMetadata + ) { + guard let state else { return } + let details = metadata().details + signposter.endInterval("terminal-renderer-frame", state, "\(details, privacy: .public)") + } + + /// Starts one coalesced renderer-update delivery interval. + @inline(__always) + public func beginUpdate( + _ metadata: @autoclosure () -> TerminalRendererProfilingMetadata + ) -> OSSignpostIntervalState? { + guard isEnabled else { return nil } + let details = metadata().details + return signposter.beginInterval( + "terminal-renderer-update", + id: signposter.makeSignpostID(), + "\(details, privacy: .public)" + ) + } + + /// Ends a coalesced renderer-update delivery interval. + @inline(__always) + public func endUpdate( + _ state: OSSignpostIntervalState?, + _ metadata: @autoclosure () -> TerminalRendererProfilingMetadata + ) { + guard let state else { return } + let details = metadata().details + signposter.endInterval("terminal-renderer-update", state, "\(details, privacy: .public)") + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingWakeReason.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingWakeReason.swift new file mode 100644 index 000000000000..db09c602125e --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Profiling/TerminalRendererProfilingWakeReason.swift @@ -0,0 +1,17 @@ +/// Typed renderer wake sources. Cases intentionally cannot carry user content. +public enum TerminalRendererProfilingWakeReason: String, Sendable { + /// Terminal output or an otherwise unattributed Ghostty renderer wake. + case terminalOutput = "terminal-output" + + /// A display-link callback requested a frame. + case displayLink = "display-link" + + /// An explicit cmux refresh requested an update. + case explicitRefresh = "explicit-refresh" + + /// Showing a previously hidden surface requested an update. + case visibility = "visibility" + + /// A focus transition requested an update. + case focus = "focus" +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Rendering/TerminalRenderedFrameReceiving.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Rendering/TerminalRenderedFrameReceiving.swift index 7184236c8ef6..6c91ba967ded 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Rendering/TerminalRenderedFrameReceiving.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Rendering/TerminalRenderedFrameReceiving.swift @@ -14,3 +14,24 @@ public protocol TerminalRenderedFrameReceiving: AnyObject, Sendable { /// Schedules a coalesced rendered-frame notification. func enqueueRenderedFrameUpdate() } + +/// Decides whether renderer output should enter the app's main-actor frame +/// delivery path. +/// +/// Renderer profiling records Ghostty and drawable-presentation intervals on +/// the renderer thread. It must not create frame-delivery demand of its own, +/// because doing so adds one main-actor task and one coalescing flush per +/// drawable to the workload being measured. +public struct TerminalRenderedFrameDeliveryPolicy: Sendable { + private let renderDemandActive: Bool + + @inline(__always) + public init(renderDemandActive: Bool) { + self.renderDemandActive = renderDemandActive + } + + @inline(__always) + public func shouldEnqueue(profilingEnabled _: Bool) -> Bool { + return renderDemandActive + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift index 31718252a459..c2f68b9c2979 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift @@ -1,5 +1,42 @@ public import Foundation public import GhosttyKit +internal import OSLog + +struct TerminalRendererProfilingStateSnapshot: Equatable, Sendable { + let visible: Bool + let focused: Bool +} + +/// Lock-backed content-free renderer state shared by the UI and renderer +/// threads. Reads wait for the tiny UI write critical section instead of +/// dropping a structural renderer event from the trace. +final class TerminalRendererProfilingStateStore: @unchecked Sendable { + private let lock = NSLock() + private var value: TerminalRendererProfilingStateSnapshot + + init(visible: Bool = true, focused: Bool = false) { + self.value = TerminalRendererProfilingStateSnapshot( + visible: visible, + focused: focused + ) + } + + func update(visible: Bool, focused: Bool) { + lock.lock() + value = TerminalRendererProfilingStateSnapshot( + visible: visible, + focused: focused + ) + lock.unlock() + } + + @inline(__always) + func snapshot() -> TerminalRendererProfilingStateSnapshot { + lock.lock() + defer { lock.unlock() } + return value + } +} /// The retained userdata handed to libghostty surface callbacks. /// @@ -9,13 +46,17 @@ public import GhosttyKit /// model and host view through the ``TerminalSurfaceControlling`` and /// ``TerminalSurfaceHosting`` seams. /// -/// Isolation: this type is intentionally not `Sendable` and holds no -/// synchronization. Both references are `weak`, the identifiers are immutable, -/// and libghostty may invoke callbacks off the main thread; callbacks read -/// the context, then hop to the main actor before touching the model or view, -/// preserving the legacy contract exactly. The owner releases the context -/// only after the runtime surface has been freed. +/// Isolation: this type is intentionally not `Sendable`. Both owner references +/// are `weak`; renderer callbacks use only immutable identity plus the tiny +/// profiling snapshot guarded below, without dereferencing either owner or +/// hopping to the main actor. The owner releases the context only after the +/// runtime surface has been freed. public final class GhosttySurfaceCallbackContext { + private let rendererState = TerminalRendererProfilingStateStore() + private let rendererEventSignposts = TerminalRendererProfilingSignposts() + nonisolated(unsafe) private var rendererEventPairing = TerminalRendererEventPairing() + nonisolated(unsafe) private var rendererUpdateState: OSSignpostIntervalState? + nonisolated(unsafe) private var rendererDrawState: OSSignpostIntervalState? /// The host view, used as a fallback identity source when the model /// reference has been released. public private(set) weak var surfaceHost: (any TerminalSurfaceHosting)? @@ -26,6 +67,12 @@ public final class GhosttySurfaceCallbackContext { /// The stable identity of the surface this context was created for. public let surfaceId: UUID + /// Stable, opaque renderer trace identity captured before any runtime recreation. + public let rendererProfilingIdentity: TerminalRendererProfilingIdentity + + /// Whether the process requested renderer profiling through the existing environment gate. + public var rendererEventProfilingRequested: Bool { rendererEventSignposts.collectionRequested } + /// Creates the callback userdata for one runtime surface. /// /// - Parameters: @@ -38,6 +85,10 @@ public final class GhosttySurfaceCallbackContext { self.surfaceHost = surfaceHost self.surfaceController = surfaceController self.surfaceId = surfaceController.surfaceId + self.rendererProfilingIdentity = TerminalRendererProfilingIdentity( + workspaceId: surfaceController.owningTabId, + surfaceId: surfaceController.surfaceId + ) } /// The owning workspace tab, read from the model first and the view as a @@ -52,4 +103,38 @@ public final class GhosttySurfaceCallbackContext { surfaceController?.runtimeSurfacePointer ?? surfaceHost?.attachedSurfaceController?.runtimeSurfacePointer } + + /// Publishes content-free UI state for the renderer callback. + public func updateRendererProfilingState(visible: Bool, focused: Bool) { + guard rendererEventSignposts.collectionRequested else { return } + rendererState.update(visible: visible, focused: focused) + } + + /// Records an exact Ghostty renderer event synchronously on its calling renderer thread. + @inline(__always) + public func recordRendererEvent(_ event: ghostty_renderer_event_e) { + guard rendererEventSignposts.isEnabled, + let event = TerminalRendererProfilingEvent(event) else { return } + let state = rendererState.snapshot() + + guard let action = rendererEventPairing.consume(event) else { return } + let metadata = TerminalRendererEventProfilingMetadata( + identity: rendererProfilingIdentity, + visible: state.visible, + focused: state.focused, + event: event + ) + switch action { + case .begin(.updateFrame): + rendererUpdateState = rendererEventSignposts.beginRendererEvent(metadata) + case .end(.updateFrame): + rendererEventSignposts.endRendererEvent(rendererUpdateState, metadata) + rendererUpdateState = nil + case .begin(.drawFrame): + rendererDrawState = rendererEventSignposts.beginRendererEvent(metadata) + case .end(.drawFrame): + rendererEventSignposts.endRendererEvent(rendererDrawState, metadata) + rendererDrawState = nil + } + } } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttySurfaceCallbackContextTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttySurfaceCallbackContextTests.swift index 29ee2f6b1ab1..d1c24ad3473b 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttySurfaceCallbackContextTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttySurfaceCallbackContextTests.swift @@ -5,7 +5,7 @@ import GhosttyKit private final class FakeSurfaceController: TerminalSurfaceControlling { let surfaceId: UUID - let owningTabId: UUID + var owningTabId: UUID var runtimeSurfacePointer: ghostty_surface_t? init( @@ -44,6 +44,24 @@ private final class FakeSurfaceHost: TerminalSurfaceHosting { #expect(context.tabId == controller.owningTabId) } + @Test func rendererProfilingIdentitySurvivesOwnerChangesAndRelease() { + let surfaceId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + let workspaceId = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + var controller: FakeSurfaceController? = FakeSurfaceController(surfaceId: surfaceId, owningTabId: workspaceId) + var host: FakeSurfaceHost? = FakeSurfaceHost() + let context = GhosttySurfaceCallbackContext( + surfaceHost: host!, + surfaceController: controller! + ) + + controller?.owningTabId = UUID() + controller = nil + host = nil + + #expect(context.rendererProfilingIdentity.surfaceId == surfaceId) + #expect(context.rendererProfilingIdentity.workspaceId == workspaceId) + } + @Test func tabIdFallsBackToHostWhenControllerReleased() { let hostTabId = UUID() let host = FakeSurfaceHost(hostedTabId: hostTabId) diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalRendererProfilingMetadataTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalRendererProfilingMetadataTests.swift new file mode 100644 index 000000000000..e43b45bb4353 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalRendererProfilingMetadataTests.swift @@ -0,0 +1,204 @@ +import Foundation +import GhosttyKit +import OSLog +import Testing +@testable import CmuxTerminalCore + +@Suite struct TerminalRendererProfilingMetadataTests { + @Test func updateDetailsContainOnlyTypedRendererState() { + let identity = TerminalRendererProfilingIdentity( + workspaceId: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + surfaceId: UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + ) + let metadata = TerminalRendererProfilingMetadata( + identity: identity, + visible: true, + focused: false, + wakeReason: .terminalOutput, + coalescedUpdateCount: 7, + dirtyRowCount: 3, + fullRedraw: false + ) + + #expect( + metadata.details == + "workspace=AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE " + + "surface=11111111-2222-3333-4444-555555555555 " + + "visible=1 focused=0 wake=terminal-output coalesced=7 dirty_rows=3 full_redraw=0" + ) + } + + @Test func metadataAPIHasNoUserContentInput() { + let identity = TerminalRendererProfilingIdentity( + workspaceId: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + surfaceId: UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + ) + let metadata = TerminalRendererProfilingMetadata( + identity: identity, + visible: false, + focused: true, + wakeReason: .displayLink, + coalescedUpdateCount: 1, + dirtyRowCount: 0, + fullRedraw: true + ) + + for forbidden in ["secret command", "/Users/example/private", "API_TOKEN=value"] { + #expect(!metadata.details.contains(forbidden)) + } + #expect(Set(metadata.details.split(separator: " ").map { $0.split(separator: "=")[0] }) == [ + "workspace", "surface", "visible", "focused", "wake", "coalesced", "dirty_rows", "full_redraw", + ]) + } +} + +@Suite struct TerminalRendererProfilingSignpostsTests { + @Test func disabledCollectionDoesNotEvaluateMetadata() { + let signposts = TerminalRendererProfilingSignposts( + signposter: OSSignposter( + subsystem: "com.cmux.terminal-renderer-tests", + category: .pointsOfInterest + ), + collectionRequested: false + ) + var evaluationCount = 0 + + func metadata() -> TerminalRendererProfilingMetadata { + evaluationCount += 1 + return TerminalRendererProfilingMetadata( + identity: TerminalRendererProfilingIdentity( + workspaceId: UUID(), + surfaceId: UUID() + ), + visible: true, + focused: true, + wakeReason: .terminalOutput, + coalescedUpdateCount: 1, + dirtyRowCount: nil, + fullRedraw: nil + ) + } + + #expect(!signposts.isEnabled) + #expect(signposts.beginFrame(metadata()) == nil) + #expect(signposts.beginUpdate(metadata()) == nil) + signposts.endFrame(nil, metadata()) + signposts.endUpdate(nil, metadata()) + #expect(evaluationCount == 0) + } + + @Test func disabledCollectionDoesNotEvaluateRendererEventMetadata() { + let signposts = TerminalRendererProfilingSignposts( + signposter: OSSignposter( + subsystem: "com.cmux.terminal-renderer-tests", + category: .pointsOfInterest + ), + collectionRequested: false + ) + var evaluationCount = 0 + + func metadata() -> TerminalRendererEventProfilingMetadata { + evaluationCount += 1 + return TerminalRendererEventProfilingMetadata( + identity: TerminalRendererProfilingIdentity( + workspaceId: UUID(), + surfaceId: UUID() + ), + visible: true, + focused: false, + event: .updateFrameBegin + ) + } + + #expect(signposts.beginRendererEvent(metadata()) == nil) + signposts.endRendererEvent(nil, metadata()) + #expect(evaluationCount == 0) + } +} + +@Suite struct TerminalRendererEventTests { + @Test func mapsEveryGhosttyRendererEventToClosedTypedState() { + #expect(TerminalRendererProfilingEvent(GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_BEGIN) == .updateFrameBegin) + #expect(TerminalRendererProfilingEvent(GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END) == .updateFrameEnd) + #expect(TerminalRendererProfilingEvent(GHOSTTY_RENDERER_EVENT_DRAW_FRAME_BEGIN) == .drawFrameBegin) + #expect(TerminalRendererProfilingEvent(GHOSTTY_RENDERER_EVENT_DRAW_FRAME_END) == .drawFrameEnd) + } + + @Test func pairsUpdateAndDrawIntervalsIndependently() { + var pairing = TerminalRendererEventPairing() + + #expect(pairing.consume(.updateFrameBegin) == .begin(.updateFrame)) + #expect(pairing.consume(.drawFrameBegin) == .begin(.drawFrame)) + #expect(pairing.consume(.drawFrameEnd) == .end(.drawFrame)) + #expect(pairing.consume(.updateFrameEnd) == .end(.updateFrame)) + #expect(pairing.consume(.updateFrameEnd) == nil) + } + + @Test func eventMetadataContainsOnlyOpaqueIdentityAndTypedState() { + let metadata = TerminalRendererEventProfilingMetadata( + identity: TerminalRendererProfilingIdentity( + workspaceId: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + surfaceId: UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + ), + visible: false, + focused: true, + event: .drawFrameEnd + ) + + #expect( + metadata.details == + "workspace=AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE " + + "surface=11111111-2222-3333-4444-555555555555 " + + "visible=0 focused=1 event=draw-frame-end" + ) + #expect(Set(metadata.details.split(separator: " ").map { $0.split(separator: "=")[0] }) == [ + "workspace", "surface", "visible", "focused", "event", + ]) + } +} + +@Suite struct TerminalRendererTraceIntegrityTests { + @Test func profilingAloneDoesNotRequestRenderedFrameDelivery() { + let inactivePolicy = TerminalRenderedFrameDeliveryPolicy(renderDemandActive: false) + let activePolicy = TerminalRenderedFrameDeliveryPolicy(renderDemandActive: true) + + #expect(!inactivePolicy.shouldEnqueue(profilingEnabled: true)) + #expect(activePolicy.shouldEnqueue(profilingEnabled: true)) + #expect(activePolicy.shouldEnqueue(profilingEnabled: false)) + } + + @Test func rendererStateSnapshotsNeverDropOrTearDuringConcurrentUpdates() async { + let store = TerminalRendererProfilingStateStore(visible: true, focused: false) + let readerCount = 8 + let readsPerReader = 5_000 + + let observedReadCount = await withTaskGroup(of: Int.self, returning: Int.self) { group in + group.addTask { + for index in 0..: Sendable { + public enum Resolution: Sendable { + case completed + case timeout + case cancelled + case workspaceRemoved + } + + public private(set) var activeWorkspaceID: WorkspaceID? + private var attemptedWorkspaceIDs: Set = [] + + public init() {} + + public mutating func nextWorkspaceID( + orderedPendingWorkspaceIDs: [WorkspaceID] + ) -> WorkspaceID? { + let pendingWorkspaceIDs = Set(orderedPendingWorkspaceIDs) + attemptedWorkspaceIDs.formIntersection(pendingWorkspaceIDs) + + if let activeWorkspaceID, + pendingWorkspaceIDs.contains(activeWorkspaceID) { + return activeWorkspaceID + } + + if let nextWorkspaceID = orderedPendingWorkspaceIDs.first(where: { + !attemptedWorkspaceIDs.contains($0) + }) { + attemptedWorkspaceIDs.insert(nextWorkspaceID) + activeWorkspaceID = nextWorkspaceID + return nextWorkspaceID + } + + attemptedWorkspaceIDs.removeAll(keepingCapacity: true) + activeWorkspaceID = orderedPendingWorkspaceIDs.first + if let activeWorkspaceID { + attemptedWorkspaceIDs.insert(activeWorkspaceID) + } + return activeWorkspaceID + } + + public mutating func resolve( + workspaceID: WorkspaceID, + resolution: Resolution + ) { + guard activeWorkspaceID == workspaceID else { return } + + switch resolution { + case .completed, .timeout, .cancelled, .workspaceRemoved: + activeWorkspaceID = nil + } + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/BackgroundWorkspaceHeadlessPrimeScheduleTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/BackgroundWorkspaceHeadlessPrimeScheduleTests.swift new file mode 100644 index 000000000000..2aa841ab5bf2 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/BackgroundWorkspaceHeadlessPrimeScheduleTests.swift @@ -0,0 +1,60 @@ +import Testing + +@testable import CmuxWorkspaces + +@Suite +struct BackgroundWorkspaceHeadlessPrimeScheduleTests { + @Test + func timeoutAdvancesWithinThePassThenWrapsForRetry() { + var schedule = BackgroundWorkspaceHeadlessPrimeSchedule() + + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first", "second"]) == "first") + #expect(schedule.activeWorkspaceID == "first") + + schedule.resolve(workspaceID: "first", resolution: .timeout) + + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first", "second"]) == "second") + #expect(schedule.activeWorkspaceID == "second") + + schedule.resolve(workspaceID: "second", resolution: .timeout) + + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first", "second"]) == "first") + } + + @Test + func completionAdvancesToTheNextPendingWorkspace() { + var schedule = BackgroundWorkspaceHeadlessPrimeSchedule() + + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first", "second"]) == "first") + schedule.resolve(workspaceID: "first", resolution: .completed) + + #expect(schedule.activeWorkspaceID == nil) + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["second"]) == "second") + } + + @Test + func cancellationAndRemovalReleaseAllPrimeState() { + var cancelledSchedule = BackgroundWorkspaceHeadlessPrimeSchedule() + #expect(cancelledSchedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first"]) == "first") + + cancelledSchedule.resolve(workspaceID: "first", resolution: .cancelled) + + #expect(cancelledSchedule.activeWorkspaceID == nil) + + var removedSchedule = BackgroundWorkspaceHeadlessPrimeSchedule() + #expect(removedSchedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first"]) == "first") + + removedSchedule.resolve(workspaceID: "first", resolution: .workspaceRemoved) + + #expect(removedSchedule.activeWorkspaceID == nil) + } + + @Test + func disappearingActiveWorkspaceSelectsOnlyOneRemainingWorkspace() { + var schedule = BackgroundWorkspaceHeadlessPrimeSchedule() + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["first", "second", "third"]) == "first") + + #expect(schedule.nextWorkspaceID(orderedPendingWorkspaceIDs: ["second", "third"]) == "second") + #expect(schedule.activeWorkspaceID == "second") + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 9a13395d3799..0d9a59f3adf6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -181963,91 +181963,6 @@ } } }, - "settings.terminal.memoryGuardrail": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Runaway Memory Guardrail" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "暴走メモリガードレール" - } - } - } - }, - "settings.terminal.memoryGuardrail.subtitleOff": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No warning is shown when a pane's process tree grows large. A leaking process can OOM-suspend the entire app." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペインのプロセスツリーが大きくなっても警告は表示されません。リークしたプロセスがアプリ全体を OOM で停止させる可能性があります。" - } - } - } - }, - "settings.terminal.memoryGuardrail.subtitleOn": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux warns you with a badge and a banner when one pane's process tree uses too much memory, so a single leak can't crash the whole app." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1つのペインのプロセスツリーが過剰なメモリを使用すると、バッジとバナーで警告します。1件のリークでアプリ全体がクラッシュするのを防ぎます。" - } - } - } - }, - "settings.terminal.memoryGuardrail.threshold": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Memory Warning Threshold (GB)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メモリ警告のしきい値(GB)" - } - } - } - }, - "settings.terminal.memoryGuardrail.threshold.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "A pane is flagged once its combined process-tree memory crosses this many gigabytes." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペインのプロセスツリーの合計メモリがこのギガバイト数を超えるとフラグが立てられます。" - } - } - } - }, "settings.terminal.rendererRealization": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+PaneMemoryGuardrail.swift b/Sources/AppDelegate+PaneMemoryGuardrail.swift index cd24f1f9bd56..7aa8b9026e71 100644 --- a/Sources/AppDelegate+PaneMemoryGuardrail.swift +++ b/Sources/AppDelegate+PaneMemoryGuardrail.swift @@ -1,17 +1,23 @@ import Foundation -extension AppDelegate { - /// Starts the per-pane runaway-memory guardrail and the central - /// memory-pressure monitor. The pane guardrail keeps its existing - /// process-tree accounting timer; global memory pressure is handled through - /// responder registration so future reclaim paths are one conformance away. - func startPaneMemoryGuardrailIfNeeded() { - let guardrail = PaneMemoryGuardrail.shared - guardrail.paneProvider = { [weak self] in - self?.paneMemoryGuardrailDescriptors() ?? [] - } - guardrail.start() - startMemoryPressureMonitorIfNeeded() +@MainActor +protocol AppMemoryMonitoringServices: AnyObject { + func startEventDrivenMemoryPressureMonitoring() +} + +/// The launch composition starts event-driven memory pressure handling. +@MainActor +struct AppMemoryMonitoringStartup { + let services: any AppMemoryMonitoringServices + + func start() { + services.startEventDrivenMemoryPressureMonitoring() + } +} + +extension AppDelegate: AppMemoryMonitoringServices { + func startMemoryMonitoringIfNeeded() { + AppMemoryMonitoringStartup(services: self).start() } func paneMemoryGuardrailDescriptors() -> [PaneMemoryDescriptor] { @@ -22,7 +28,7 @@ extension AppDelegate { } } - private func startMemoryPressureMonitorIfNeeded() { + func startEventDrivenMemoryPressureMonitoring() { let monitor = MemoryPressureMonitor.shared monitor.registry.register( RendererRealizationMemoryPressureResponder( @@ -45,6 +51,18 @@ extension AppDelegate { monitor.start() } +#if DEBUG + /// Explicit LLDB/debug-command hook for a single attributed pane-memory + /// snapshot. It never installs a timer. + func runPaneMemoryDiagnosticOnce() { + let guardrail = PaneMemoryGuardrail.shared + guardrail.paneProvider = { [weak self] in + self?.paneMemoryGuardrailDescriptors() ?? [] + } + guardrail.runDiagnosticOnce() + } +#endif + private func postPersistentCriticalMemoryPressureWarning(snapshot: MemoryPressureSnapshot) { guard let notificationStore else { return } let managers = paneMemoryGuardrailTabManagers() diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 259345f6d936..8740c6c09a56 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2054,7 +2054,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent agentChatTranscriptService.start() installMobileHostSettingsObserver() scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore) - startPaneMemoryGuardrailIfNeeded() + startMemoryMonitoringIfNeeded() disableSuddenTerminationIfNeeded() installLifecycleSnapshotObserversIfNeeded() // Seed so the first display change after launch can restore geometry. @@ -15954,7 +15954,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { [weak self] notification in guard let self else { return } guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() + // BrowserPanel owns suppression through pending intent and mounted-view + // leases. The app delegate only tracks shortcut routing ownership. self.browserAddressBarFocusedPanelId = panelId self.stopBrowserOmnibarSelectionRepeat() #if DEBUG @@ -15969,7 +15970,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { [weak self] notification in guard let self else { return } guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() if self.browserAddressBarFocusedPanelId == panelId { self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() diff --git a/Sources/BackgroundWorkspacePrimeCoordinator.swift b/Sources/BackgroundWorkspacePrimeCoordinator.swift index d5cc86e59489..c82b4f797ce6 100644 --- a/Sources/BackgroundWorkspacePrimeCoordinator.swift +++ b/Sources/BackgroundWorkspacePrimeCoordinator.swift @@ -1,9 +1,30 @@ import Foundation import Combine import CmuxTerminal +import CmuxWorkspaces + +enum BackgroundWorkspacePrimeWorkState: Sendable { + case workspaceRemoved + case noSurfaceWork + case needsSurfaceStart + case ready +} + +@MainActor +protocol BackgroundWorkspacePrimeHosting: AnyObject, Sendable { + var pendingBackgroundWorkspaceLoadIds: Set { get } + var backgroundWorkspacePrimePendingPublisher: AnyPublisher, Never> { get } + var backgroundWorkspacePrimeWorkspaceIDsPublisher: AnyPublisher, Never> { get } + + func backgroundWorkspacePrimeWorkState(for workspaceID: UUID) -> BackgroundWorkspacePrimeWorkState + func requestBackgroundWorkspacePrimeSurfaceStart(for workspaceID: UUID) + func completeBackgroundWorkspaceLoad(for workspaceID: UUID) +} @MainActor final class BackgroundWorkspacePrimeCoordinator { + typealias TimeoutSleep = @Sendable () async throws -> Void + private nonisolated enum PrimeCompletionReason: String { case alreadyCleared = "already_cleared" case cancelled @@ -18,9 +39,7 @@ final class BackgroundWorkspacePrimeCoordinator { case completed(reason: PrimeCompletionReason) } - private nonisolated enum Policy { - static let timeoutSeconds: TimeInterval = 2.0 - } + private let timeoutSleep: TimeoutSleep private nonisolated final class Waiter: @unchecked Sendable { // Cancellation handlers cannot await an actor hop; this lock keeps continuation @@ -95,61 +114,85 @@ final class BackgroundWorkspacePrimeCoordinator { } } + init(timeoutSleep: @escaping TimeoutSleep = { + try await Task.sleep(nanoseconds: 2_000_000_000) + }) { + self.timeoutSleep = timeoutSleep + } + deinit { // Explicit for the required_deinit lint; per-prime resources live on Waiter. } - func taskKey(for tabManager: TabManager) -> [String] { - tabManager.pendingBackgroundWorkspaceLoadIds + func taskKey(for host: any BackgroundWorkspacePrimeHosting) -> [String] { + host.pendingBackgroundWorkspaceLoadIds .map(\.uuidString) .sorted() } func primePendingBackgroundWorkspaces(tabManager: TabManager) async { + await primePendingBackgroundWorkspaces(host: tabManager) + } + + func primePendingBackgroundWorkspaces(host: any BackgroundWorkspacePrimeHosting) async { + var schedule = BackgroundWorkspaceHeadlessPrimeSchedule() + while !Task.isCancelled { - let workspaceIds = tabManager.pendingBackgroundWorkspaceLoadIds.sorted { $0.uuidString < $1.uuidString } - guard !workspaceIds.isEmpty else { return } - for workspaceId in workspaceIds { - guard !Task.isCancelled else { return } - let reason = await primeBackgroundWorkspaceIfNeeded(workspaceId: workspaceId, tabManager: tabManager) - guard !Task.isCancelled else { return } - - switch reason { - case .timeout: - // Keep the hidden mount retained; pending background initial commands - // must stay eligible to start until the surface is actually ready. - continue - case .cancelled: - continue - case .alreadyCleared, .noSurfaceWork, .surfaceReady, .workspaceRemoved: - continue - } + let workspaceIds = host.pendingBackgroundWorkspaceLoadIds + .sorted { $0.uuidString < $1.uuidString } + guard let workspaceId = schedule.nextWorkspaceID( + orderedPendingWorkspaceIDs: workspaceIds + ) else { + return + } + let reason = await primeBackgroundWorkspaceIfNeeded( + workspaceId: workspaceId, + host: host + ) + guard !Task.isCancelled else { + schedule.resolve(workspaceID: workspaceId, resolution: .cancelled) + return + } + + switch reason { + case .timeout: + schedule.resolve(workspaceID: workspaceId, resolution: .timeout) + case .cancelled: + schedule.resolve(workspaceID: workspaceId, resolution: .cancelled) + return + case .workspaceRemoved: + schedule.resolve(workspaceID: workspaceId, resolution: .workspaceRemoved) + case .alreadyCleared, .noSurfaceWork, .surfaceReady: + schedule.resolve(workspaceID: workspaceId, resolution: .completed) } } } private func primeBackgroundWorkspaceIfNeeded( workspaceId: UUID, - tabManager: TabManager + host: any BackgroundWorkspacePrimeHosting ) async -> PrimeCompletionReason { - guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { - tabManager.releaseBackgroundWorkspaceMount(for: workspaceId) + guard host.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return .alreadyCleared } - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + + switch host.backgroundWorkspacePrimeWorkState(for: workspaceId) { + case .workspaceRemoved: + host.completeBackgroundWorkspaceLoad(for: workspaceId) return .workspaceRemoved - } - guard workspace.hasBackgroundPrimeTerminalSurfaceStartWork() else { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + case .noSurfaceWork: + host.completeBackgroundWorkspaceLoad(for: workspaceId) return .noSurfaceWork - } - guard !workspace.hasLoadedBackgroundPrimeTerminalSurface() else { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + case .ready: + host.completeBackgroundWorkspaceLoad(for: workspaceId) return .surfaceReady + case .needsSurfaceStart: + break } - tabManager.retainBackgroundWorkspaceMount(for: workspaceId) + // CmuxTerminal starts deferred terminal runtimes in its own invisible AppKit host. + // The coordinator can therefore start the runtime without building a background + // SwiftUI workspace tree. #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime @@ -157,14 +200,13 @@ final class BackgroundWorkspacePrimeCoordinator { #endif let completionReason: PrimeCompletionReason - switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) { + switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, host: host) { case .completed(let reason): completionReason = reason case .pending: completionReason = await waitForBackgroundWorkspacePrimeCompletion( workspaceId: workspaceId, - timeoutSeconds: Policy.timeoutSeconds, - tabManager: tabManager + host: host ) } @@ -178,37 +220,48 @@ final class BackgroundWorkspacePrimeCoordinator { return completionReason } - private func stepBackgroundWorkspacePrime(workspaceId: UUID, tabManager: TabManager) -> PrimeState { - guard tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { - tabManager.releaseBackgroundWorkspaceMount(for: workspaceId) + private func stepBackgroundWorkspacePrime( + workspaceId: UUID, + host: any BackgroundWorkspacePrimeHosting + ) -> PrimeState { + guard host.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return .completed(reason: .alreadyCleared) } - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + + switch host.backgroundWorkspacePrimeWorkState(for: workspaceId) { + case .workspaceRemoved: + host.completeBackgroundWorkspaceLoad(for: workspaceId) return .completed(reason: .workspaceRemoved) - } - guard workspace.hasBackgroundPrimeTerminalSurfaceStartWork() else { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + case .noSurfaceWork: + host.completeBackgroundWorkspaceLoad(for: workspaceId) return .completed(reason: .noSurfaceWork) - } - guard !workspace.hasLoadedBackgroundPrimeTerminalSurface() else { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + case .ready: + host.completeBackgroundWorkspaceLoad(for: workspaceId) return .completed(reason: .surfaceReady) + case .needsSurfaceStart: + break } - workspace.requestBackgroundPrimeTerminalSurfaceStartIfNeeded() - guard workspace.hasLoadedBackgroundPrimeTerminalSurface() else { + host.requestBackgroundWorkspacePrimeSurfaceStart(for: workspaceId) + + switch host.backgroundWorkspacePrimeWorkState(for: workspaceId) { + case .workspaceRemoved: + host.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: .workspaceRemoved) + case .noSurfaceWork: + host.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: .noSurfaceWork) + case .needsSurfaceStart: return .pending + case .ready: + host.completeBackgroundWorkspaceLoad(for: workspaceId) + return .completed(reason: .surfaceReady) } - - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) - return .completed(reason: .surfaceReady) } private func waitForBackgroundWorkspacePrimeCompletion( workspaceId: UUID, - timeoutSeconds: TimeInterval, - tabManager: TabManager + host: any BackgroundWorkspacePrimeHosting ) async -> PrimeCompletionReason { let waiter = Waiter() return await withTaskCancellationHandler { @@ -219,20 +272,20 @@ final class BackgroundWorkspacePrimeCoordinator { installReadinessObservers( waiter: waiter, workspaceId: workspaceId, - tabManager: tabManager + host: host ) - let timeoutNanoseconds = UInt64(timeoutSeconds * 1_000_000_000) - let timeoutTask = Task { @MainActor [weak self, weak waiter, weak tabManager] in + let timeoutSleep = self.timeoutSleep + let timeoutTask = Task { @MainActor [weak self, weak waiter, weak host] in do { - try await Task.sleep(nanoseconds: timeoutNanoseconds) + try await timeoutSleep() } catch { return } - guard !Task.isCancelled, let self, let waiter, let tabManager else { return } + guard !Task.isCancelled, let self, let waiter, let host else { return } if case .completed(let reason) = self.stepBackgroundWorkspacePrime( workspaceId: workspaceId, - tabManager: tabManager + host: host ) { waiter.finish(reason: reason) } else { @@ -241,7 +294,7 @@ final class BackgroundWorkspacePrimeCoordinator { } waiter.addTask(timeoutTask) - evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager) + evaluate(waiter: waiter, workspaceId: workspaceId, host: host) } } onCancel: { waiter.finish(reason: .cancelled) @@ -251,20 +304,20 @@ final class BackgroundWorkspacePrimeCoordinator { private func installReadinessObservers( waiter: Waiter, workspaceId: UUID, - tabManager: TabManager + host: any BackgroundWorkspacePrimeHosting ) { let readyObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, object: nil, queue: .main - ) { [weak self, weak waiter, weak tabManager] notification in + ) { [weak self, weak waiter, weak host] notification in guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, readyWorkspaceId == workspaceId, let self, let waiter, - let tabManager else { return } + let host else { return } Task { @MainActor in - self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager) + self.evaluate(waiter: waiter, workspaceId: workspaceId, host: host) } } waiter.addObserver(readyObserver) @@ -273,47 +326,51 @@ final class BackgroundWorkspacePrimeCoordinator { forName: .terminalSurfaceHostedViewDidMoveToWindow, object: nil, queue: .main - ) { [weak self, weak waiter, weak tabManager] notification in + ) { [weak self, weak waiter, weak host] notification in guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, readyWorkspaceId == workspaceId, let self, let waiter, - let tabManager else { return } + let host else { return } Task { @MainActor in - self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager) + self.evaluate(waiter: waiter, workspaceId: workspaceId, host: host) } } waiter.addObserver(hostedViewObserver) - let pendingObserver = tabManager.$pendingBackgroundWorkspaceLoadIds + let pendingObserver = host.backgroundWorkspacePrimePendingPublisher .dropFirst() - .sink { [weak self, weak waiter, weak tabManager] pendingIds in + .sink { [weak self, weak waiter, weak host] pendingIds in guard !pendingIds.contains(workspaceId), let self, let waiter, - let tabManager else { return } + let host else { return } Task { @MainActor in - self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager) + self.evaluate(waiter: waiter, workspaceId: workspaceId, host: host) } } waiter.addCancellable(pendingObserver) - let tabsObserver = tabManager.tabsPublisher + let tabsObserver = host.backgroundWorkspacePrimeWorkspaceIDsPublisher .dropFirst() - .sink { [weak self, weak waiter, weak tabManager] tabs in - guard !tabs.contains(where: { $0.id == workspaceId }), + .sink { [weak self, weak waiter, weak host] workspaceIDs in + guard !workspaceIDs.contains(workspaceId), let self, let waiter, - let tabManager else { return } + let host else { return } Task { @MainActor in - self.evaluate(waiter: waiter, workspaceId: workspaceId, tabManager: tabManager) + self.evaluate(waiter: waiter, workspaceId: workspaceId, host: host) } } waiter.addCancellable(tabsObserver) } - private func evaluate(waiter: Waiter, workspaceId: UUID, tabManager: TabManager) { - switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) { + private func evaluate( + waiter: Waiter, + workspaceId: UUID, + host: any BackgroundWorkspacePrimeHosting + ) { + switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, host: host) { case .pending: break case .completed(let reason): @@ -321,3 +378,33 @@ final class BackgroundWorkspacePrimeCoordinator { } } } + +extension TabManager: BackgroundWorkspacePrimeHosting { + var backgroundWorkspacePrimePendingPublisher: AnyPublisher, Never> { + $pendingBackgroundWorkspaceLoadIds.eraseToAnyPublisher() + } + + var backgroundWorkspacePrimeWorkspaceIDsPublisher: AnyPublisher, Never> { + tabsPublisher + .map { Set($0.map(\.id)) } + .eraseToAnyPublisher() + } + + func backgroundWorkspacePrimeWorkState(for workspaceID: UUID) -> BackgroundWorkspacePrimeWorkState { + guard let workspace = tabs.first(where: { $0.id == workspaceID }) else { + return .workspaceRemoved + } + guard workspace.hasBackgroundPrimeTerminalSurfaceStartWork() else { + return .noSurfaceWork + } + guard !workspace.hasLoadedBackgroundPrimeTerminalSurface() else { + return .ready + } + return .needsSurfaceStart + } + + func requestBackgroundWorkspacePrimeSurfaceStart(for workspaceID: UUID) { + tabs.first(where: { $0.id == workspaceID })? + .requestBackgroundPrimeTerminalSurfaceStartIfNeeded() + } +} diff --git a/Sources/CmuxEventBus.swift b/Sources/CmuxEventBus.swift index 00008db1075a..e467cea86590 100644 --- a/Sources/CmuxEventBus.swift +++ b/Sources/CmuxEventBus.swift @@ -1,8 +1,95 @@ import Foundation +struct CmuxBoundedRingBuffer { + private var storage: [Element?] + private(set) var count = 0 + private var head = 0 + + let capacity: Int + + init(capacity: Int) { + self.capacity = max(1, capacity) + self.storage = Array(repeating: nil, count: self.capacity) + } + + var isEmpty: Bool { count == 0 } + + var first: Element? { + guard count > 0 else { return nil } + return storage[head] + } + + var elements: [Element] { + guard count > 0 else { return [] } + return (0.. Bool { + if count < capacity { + storage[(head + count) % capacity] = element + count += 1 + return false + } + + storage[head] = element + head = (head + 1) % capacity + return true + } + + mutating func removeFirst() -> Element? { + guard count > 0 else { return nil } + let element = storage[head] + storage[head] = nil + head = (head + 1) % capacity + count -= 1 + if count == 0 { head = 0 } + return element + } + + mutating func removeAll() { + for offset in 0.. private var closed = false private var closedReason: String? @@ -24,14 +111,15 @@ final class CmuxEventSubscription: @unchecked Sendable { self.names = names self.categories = categories self.maxPendingEvents = max(1, maxPendingEvents) + self.queue = CmuxBoundedRingBuffer(capacity: self.maxPendingEvents) } - func accepts(_ event: [String: Any]) -> Bool { + func accepts(_ frame: CmuxEventFrame) -> Bool { if !names.isEmpty { - guard let name = event["name"] as? String, names.contains(name) else { return false } + guard names.contains(frame.name) else { return false } } if !categories.isEmpty { - guard let category = event["category"] as? String, categories.contains(category) else { return false } + guard categories.contains(frame.category) else { return false } } return true } @@ -48,7 +136,7 @@ final class CmuxEventSubscription: @unchecked Sendable { return closedReason } - func enqueue(_ event: [String: Any]) -> Bool { + func enqueue(_ frame: CmuxEventFrame) -> Bool { lock.lock() let shouldSignal: Bool let accepted: Bool @@ -62,7 +150,7 @@ final class CmuxEventSubscription: @unchecked Sendable { shouldSignal = true accepted = false } else { - queue.append(event) + queue.appendDroppingOldest(frame) shouldSignal = true accepted = true } @@ -74,11 +162,15 @@ final class CmuxEventSubscription: @unchecked Sendable { } func next(timeout: TimeInterval) -> [String: Any]? { + nextFrame(timeout: timeout)?.object + } + + func nextFrame(timeout: TimeInterval) -> CmuxEventFrame? { lock.lock() if !queue.isEmpty { - let event = queue.removeFirst() + let frame = queue.removeFirst() lock.unlock() - return event + return frame } if closed { lock.unlock() @@ -130,9 +222,11 @@ final class CmuxEventBus: @unchecked Sendable { private let maxEventLineBytes: Int private let maxPendingEventsPerSubscription: Int private let eventLogWriter: CmuxEventLogWriter? + private let sanitize: (Any) -> Any + private let encodeSanitizedLine: ([String: Any]) -> String? private let bootId = UUID().uuidString private var nextSequence: Int64 = 1 - private var retained: [[String: Any]] = [] + private var retained: CmuxBoundedRingBuffer private var subscriptions: [UUID: CmuxEventSubscription] = [:] init( @@ -141,11 +235,16 @@ final class CmuxEventBus: @unchecked Sendable { maxEventLogBytes: UInt64 = CmuxEventBus.defaultMaxEventLogBytes, maxEventLineBytes: Int = CmuxEventBus.defaultMaxEventLineBytes, maxPendingEventLogLines: Int = CmuxEventBus.defaultMaxPendingEventLogLines, - maxPendingEventsPerSubscription: Int = CmuxEventBus.defaultMaxPendingEventsPerSubscription + maxPendingEventsPerSubscription: Int = CmuxEventBus.defaultMaxPendingEventsPerSubscription, + sanitize: @escaping (Any) -> Any = CmuxEventBus.sanitizedJSONValue, + encodeSanitizedLine: @escaping ([String: Any]) -> String? = CmuxEventBus.encodeSanitizedLine ) { self.retainedEventLimit = max(1, retainedEventLimit) self.maxEventLineBytes = max(1, maxEventLineBytes) self.maxPendingEventsPerSubscription = max(1, maxPendingEventsPerSubscription) + self.sanitize = sanitize + self.encodeSanitizedLine = encodeSanitizedLine + self.retained = CmuxBoundedRingBuffer(capacity: self.retainedEventLimit) self.eventLogWriter = eventLogURL.map { CmuxEventLogWriter( eventLogURL: $0, @@ -172,13 +271,12 @@ final class CmuxEventBus: @unchecked Sendable { payload: [String: Any] = [:] ) { let occurredAt = Self.isoTimestamp(Date()) - let cleanPayload = Self.sanitizedJSONValue(payload) lock.lock() + defer { lock.unlock() } let sequence = nextSequence - nextSequence += 1 - var event: [String: Any] = [ + let unsanitizedEvent: [String: Any] = [ "type": "event", "protocol": Self.protocolName, "version": Self.protocolVersion, @@ -193,25 +291,24 @@ final class CmuxEventBus: @unchecked Sendable { "surface_id": surfaceId ?? NSNull(), "pane_id": paneId ?? NSNull(), "window_id": windowId ?? NSNull(), - "payload": cleanPayload + "payload": payload ] + guard let event = sanitize(unsanitizedEvent) as? [String: Any], + let frame = makeFrame(event: event, sequence: sequence) else { return } - event = Self.eventByApplyingEncodedByteLimit(event, maxBytes: maxEventLineBytes) - retained.append(event) - if retained.count > retainedEventLimit { - retained.removeFirst(retained.count - retainedEventLimit) - } - let encodedLine = Self.encodeLine(event) - let liveSubscriptions = Array(subscriptions.values) - lock.unlock() - - if let encodedLine { eventLogWriter?.enqueue(encodedLine) } + nextSequence += 1 + retained.appendDroppingOldest(frame) + eventLogWriter?.enqueue(frame.wireData) - for subscription in liveSubscriptions where subscription.accepts(event) { - if !subscription.enqueue(event) { - removeSubscriptionIfStillActive(subscription) + var closedSubscriptionIds: [UUID] = [] + for (id, subscription) in subscriptions where subscription.accepts(frame) { + if !subscription.enqueue(frame) { + closedSubscriptionIds.append(id) } } + for id in closedSubscriptionIds { + subscriptions.removeValue(forKey: id) + } } func subscribe( @@ -226,12 +323,12 @@ final class CmuxEventBus: @unchecked Sendable { ) lock.lock() - let oldestSequence = Self.int64(retained.first?["seq"]) ?? nextSequence + let retainedFrames = retained.elements + let oldestSequence = retained.first?.sequence ?? nextSequence let latestSequence = nextSequence - 1 - let replay = retained.filter { event in - let seq = Self.int64(event["seq"]) ?? 0 + let replayFrames = retainedFrames.filter { frame in let after = afterSequence ?? latestSequence - return seq > after && subscription.accepts(event) + return frame.sequence > after && subscription.accepts(frame) } let requestedAfter = afterSequence ?? latestSequence let gapReason: String? = afterSequence.flatMap { after in @@ -266,7 +363,7 @@ final class CmuxEventBus: @unchecked Sendable { "boot_id": bootId, "subscription_id": subscription.id.uuidString, "heartbeat_interval_seconds": NSNumber(value: Self.defaultHeartbeatIntervalSeconds), - "replay_count": replay.count, + "replay_count": replayFrames.count, "resume": resume, "filters": [ "names": Array(names).sorted(), @@ -274,7 +371,12 @@ final class CmuxEventBus: @unchecked Sendable { ] ] - return CmuxEventSubscriptionSnapshot(subscription: subscription, replay: replay, ack: ack) + return CmuxEventSubscriptionSnapshot( + subscription: subscription, + replay: replayFrames.map(\.object), + replayFrames: replayFrames, + ack: ack + ) } func unsubscribe(_ subscription: CmuxEventSubscription) { @@ -284,14 +386,6 @@ final class CmuxEventBus: @unchecked Sendable { subscription.close() } - private func removeSubscriptionIfStillActive(_ subscription: CmuxEventSubscription) { - lock.lock() - if subscriptions[subscription.id] === subscription { - subscriptions.removeValue(forKey: subscription.id) - } - lock.unlock() - } - func heartbeat(subscription: CmuxEventSubscription) -> [String: Any] { [ "type": "heartbeat", @@ -307,7 +401,7 @@ final class CmuxEventBus: @unchecked Sendable { func retainedSnapshot() -> [[String: Any]] { lock.lock() defer { lock.unlock() } - return retained + return retained.elements.map(\.object) } #if DEBUG @@ -343,8 +437,13 @@ final class CmuxEventBus: @unchecked Sendable { static func encodeLine(_ object: [String: Any]) -> String? { let clean = sanitizedJSONValue(object) - guard JSONSerialization.isValidJSONObject(clean), - let data = try? JSONSerialization.data(withJSONObject: clean, options: [.sortedKeys]), + guard let cleanObject = clean as? [String: Any] else { return nil } + return encodeSanitizedLine(cleanObject) + } + + static func encodeSanitizedLine(_ object: [String: Any]) -> String? { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), let string = String(data: data, encoding: .utf8) else { return nil } @@ -421,11 +520,13 @@ final class CmuxEventBus: @unchecked Sendable { } } - private static func eventByApplyingEncodedByteLimit(_ event: [String: Any], maxBytes: Int) -> [String: Any] { - guard maxBytes > 0, - let line = encodeLine(event), - line.utf8.count > maxBytes else { - return event + private func makeFrame( + event: [String: Any], + sequence: Int64 + ) -> CmuxEventFrame? { + guard let encodedLine = encodeSanitizedLine(event) else { return nil } + guard encodedLine.utf8.count > maxEventLineBytes else { + return makeFrame(object: event, encodedLine: encodedLine, sequence: sequence) } var compact = event @@ -434,20 +535,68 @@ final class CmuxEventBus: @unchecked Sendable { compact["payload"] = [ "truncated": true, "reason": "event exceeded max encoded byte limit", - "max_bytes": maxBytes, + "max_bytes": maxEventLineBytes, "original_payload_keys": Array(payload.keys.sorted().prefix(64)) ] - if let line = encodeLine(compact), line.utf8.count <= maxBytes { - return compact + if let line = encodeSanitizedLine(compact), line.utf8.count <= maxEventLineBytes { + return makeFrame(object: compact, encodedLine: line, sequence: sequence) } compact["payload"] = [ "truncated": true, "reason": "event exceeded max encoded byte limit", - "max_bytes": maxBytes + "max_bytes": maxEventLineBytes ] - return compact + for stringBudget in Self.topLevelMetadataBudgets(maxEventLineBytes: maxEventLineBytes) { + var bounded = compact + for key in Self.userControlledTopLevelStringKeys { + if let value = bounded[key] as? String { + bounded[key] = Self.truncatedString(value, maxUTF8Bytes: stringBudget) + } + } + guard let line = encodeSanitizedLine(bounded) else { continue } + if line.utf8.count <= maxEventLineBytes { + return makeFrame(object: bounded, encodedLine: line, sequence: sequence) + } + } + + return nil + } + + private func makeFrame( + object: [String: Any], + encodedLine: String, + sequence: Int64 + ) -> CmuxEventFrame { + let name = object["name"] as? String ?? "" + let category = object["category"] as? String ?? "" + return CmuxEventFrame( + object: object, + encodedLine: encodedLine, + sequence: sequence, + name: name, + category: category + ) + } + + private static let userControlledTopLevelStringKeys = [ + "name", + "category", + "source", + "workspace_id", + "surface_id", + "pane_id", + "window_id" + ] + + private static func topLevelMetadataBudgets(maxEventLineBytes: Int) -> [Int] { + let proportionalBudget = max(3, min(256, maxEventLineBytes / 16)) + return [proportionalBudget, 128, 64, 32, 16, 8, 3] + .filter { $0 <= proportionalBudget } + .reduce(into: []) { budgets, budget in + if budgets.last != budget { budgets.append(budget) } + } } private static func truncatedString(_ value: String, maxUTF8Bytes: Int) -> String { diff --git a/Sources/CmuxEventLogWriter.swift b/Sources/CmuxEventLogWriter.swift index fc6aa674c9c0..8e17467c2895 100644 --- a/Sources/CmuxEventLogWriter.swift +++ b/Sources/CmuxEventLogWriter.swift @@ -13,7 +13,7 @@ final class CmuxEventLogWriter: @unchecked Sendable { private let maxEventLogBytes: UInt64 private let maxPendingLines: Int private let lock = NSLock() - private var pendingLines: [String] = [] + private var pendingLines: CmuxBoundedRingBuffer private var flushScheduled = false private var droppedLineCount = 0 #if DEBUG @@ -24,17 +24,15 @@ final class CmuxEventLogWriter: @unchecked Sendable { self.eventLogURL = eventLogURL self.maxEventLogBytes = max(1, maxEventLogBytes) self.maxPendingLines = max(1, maxPendingLines) + self.pendingLines = CmuxBoundedRingBuffer(capacity: self.maxPendingLines) } - func enqueue(_ line: String) { + func enqueue(_ line: Data) { var shouldSchedule = false lock.lock() - if pendingLines.count >= maxPendingLines { - let removedCount = pendingLines.count - maxPendingLines + 1 - pendingLines.removeFirst(removedCount) - droppedLineCount += removedCount + if pendingLines.appendDroppingOldest(line) { + droppedLineCount += 1 } - pendingLines.append(line) #if DEBUG if flushSuspendedForTesting { lock.unlock() @@ -105,7 +103,7 @@ final class CmuxEventLogWriter: @unchecked Sendable { private func flushPendingLines() { while true { - let lines: [String] + let lines: [Data] let droppedCount: Int lock.lock() if pendingLines.isEmpty { @@ -118,14 +116,14 @@ final class CmuxEventLogWriter: @unchecked Sendable { } return } - lines = pendingLines - pendingLines.removeAll(keepingCapacity: true) + lines = pendingLines.elements + pendingLines.removeAll() lock.unlock() append(lines) } } - private func append(_ lines: [String]) { + private func append(_ lines: [Data]) { guard !lines.isEmpty else { return } do { try FileManager.default.createDirectory( @@ -140,8 +138,7 @@ final class CmuxEventLogWriter: @unchecked Sendable { defer { try? handle.close() } try handle.seekToEnd() var currentSize = Self.fileSize(at: eventLogURL, fileManager: fileManager) - for line in lines { - let data = Data((line + "\n").utf8) + for data in lines { if currentSize + UInt64(data.count) > maxEventLogBytes { try handle.close() try rotate(fileManager: fileManager) diff --git a/Sources/CmuxEventStream.swift b/Sources/CmuxEventStream.swift index 40ec82a934c3..001f83405489 100644 --- a/Sources/CmuxEventStream.swift +++ b/Sources/CmuxEventStream.swift @@ -38,13 +38,13 @@ extension TerminalController { defer { CmuxEventBus.shared.unsubscribe(snapshot.subscription) } guard writeEventsStreamLine(snapshot.ack, socket: socket) else { return } - for event in snapshot.replay { - guard writeEventsStreamLine(event, socket: socket) else { return } + for frame in snapshot.replayFrames { + guard writeEventsStreamFrame(frame, socket: socket) else { return } } while true { - if let event = snapshot.subscription.next(timeout: CmuxEventBus.defaultHeartbeatIntervalSeconds) { - guard writeEventsStreamLine(event, socket: socket) else { return } + if let frame = snapshot.subscription.nextFrame(timeout: CmuxEventBus.defaultHeartbeatIntervalSeconds) { + guard writeEventsStreamFrame(frame, socket: socket) else { return } } else if snapshot.subscription.isClosed { if let reason = snapshot.subscription.closeReason { _ = writeEventsStreamLine([ @@ -78,6 +78,10 @@ extension TerminalController { } } + private nonisolated func writeEventsStreamFrame(_ frame: CmuxEventFrame, socket: Int32) -> Bool { + transport.writeAll(frame.wireData, to: socket) + } + private nonisolated static func stringSet(_ value: Any?) -> Set { if let string = value as? String { let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CmuxTopProcessArguments.swift b/Sources/CmuxTopProcessArguments.swift index 9f353d0116fa..cc91311f2ade 100644 --- a/Sources/CmuxTopProcessArguments.swift +++ b/Sources/CmuxTopProcessArguments.swift @@ -59,7 +59,88 @@ extension CmuxTopProcessSnapshot { return CmuxTopProcessArguments(arguments: arguments, environment: environment) } - private static func kernProcArgsBytes(for pid: Int) -> [UInt8]? { + /// Reads only the environment keys that choose a project-local cmux config. + /// The full argv/environment buffer is intentionally left undecoded so callers can + /// discard noncandidate process buffers without allocating every argument and value. + static func processProjectWorkingDirectory(fromKernProcArgs bytes: [UInt8]) -> String? { + guard bytes.count > MemoryLayout.size else { return nil } + + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in + rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout.size)) + } + let argc = Int(Int32(littleEndian: argcRaw)) + guard argc > 0 else { return nil } + + var index = MemoryLayout.size + skipString(in: bytes, index: &index) + skipNulls(in: bytes, index: &index) + for _ in 0.. Bool { + guard !normalizedNeedles.isEmpty, + bytes.count > MemoryLayout.size else { return false } + + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in + rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout.size)) + } + let argc = Int(Int32(littleEndian: argcRaw)) + guard argc > 0 else { return false } + + var index = MemoryLayout.size + skipString(in: bytes, index: &index) + skipNulls(in: bytes, index: &index) + for _ in 0.. [UInt8]? { var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] var size: size_t = 0 guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, @@ -71,8 +152,52 @@ extension CmuxTopProcessSnapshot { let success = buffer.withUnsafeMutableBytes { rawBuffer in sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0 } - guard success else { return nil } - return Array(buffer.prefix(Int(size))) + guard success, size <= buffer.count else { return nil } + buffer.removeLast(buffer.count - Int(size)) + return buffer + } + + private static func environmentValue( + in entry: ArraySlice, + prefix: [UInt8] + ) -> String? { + guard entry.count >= prefix.count, + entry.prefix(prefix.count).elementsEqual(prefix) else { + return nil + } + return String(bytes: entry.dropFirst(prefix.count), encoding: .utf8) + } + + private static let launchWorkingDirectoryEnvironmentPrefix = Array( + "CMUX_AGENT_LAUNCH_CWD=".utf8 + ) + private static let pwdEnvironmentPrefix = Array("PWD=".utf8) + + private static func argumentContains( + _ argument: ArraySlice, + needle: [UInt8] + ) -> Bool { + guard !needle.isEmpty, argument.count >= needle.count else { return false } + let lastStart = argument.count - needle.count + for offset in 0...lastStart { + let start = argument.index(argument.startIndex, offsetBy: offset) + var matches = true + for needleOffset in needle.indices { + let argumentIndex = argument.index(start, offsetBy: needleOffset) + if normalizedArgumentByte(argument[argumentIndex]) != needle[needleOffset] { + matches = false + break + } + } + if matches { return true } + } + return false + } + + private static func normalizedArgumentByte(_ byte: UInt8) -> UInt8 { + if byte == 0x5C { return 0x2F } // Backslash to slash. + if byte >= 0x41, byte <= 0x5A { return byte + 0x20 } + return byte } private static func skipString(in bytes: [UInt8], index: inout Int) { diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index 89531944e13f..8183f315dcd3 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -30,7 +30,8 @@ private nonisolated final class CmuxTopProcessCPUTracker: @unchecked Sendable { for currentSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], activeKeys: Set, parentKeysByKey: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheKey], - sampledAtNanoseconds: UInt64 + sampledAtNanoseconds: UInt64, + allowsPruning: Bool ) -> [CmuxTopProcessScopeCacheKey: Double] { state.withLock { state in var percentages: [CmuxTopProcessScopeCacheKey: Double] = [:] @@ -75,19 +76,22 @@ private nonisolated final class CmuxTopProcessCPUTracker: @unchecked Sendable { ) } - for (key, entry) in state.entries where entry.cpuPercent > 0 { - guard let parentKey = entry.parentKey, - !activeKeys.contains(key), - activeKeys.contains(parentKey), - !heldKeys.contains(parentKey) else { - continue + if allowsPruning { + for (key, entry) in state.entries where entry.cpuPercent > 0 { + guard let parentKey = entry.parentKey, + !activeKeys.contains(key), + activeKeys.contains(parentKey), + !heldKeys.contains(parentKey) else { + continue + } + percentages[parentKey, default: 0] += entry.cpuPercent } - percentages[parentKey, default: 0] += entry.cpuPercent } // Overlapping captures can finish out of sample-time order; only // the newest completed capture is allowed to evict inactive keys. - if sampledAtNanoseconds >= state.latestPrunedAtNanoseconds { + if allowsPruning, + sampledAtNanoseconds >= state.latestPrunedAtNanoseconds { state.latestPrunedAtNanoseconds = sampledAtNanoseconds state.entries = state.entries.filter { entry in activeKeys.contains(entry.key) @@ -117,13 +121,15 @@ nonisolated extension CmuxTopProcessSnapshot { for samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], activeKeys: Set, parentKeysByKey: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheKey] = [:], - sampledAtNanoseconds: UInt64 + sampledAtNanoseconds: UInt64, + allowsPruning: Bool = true ) -> [CmuxTopProcessScopeCacheKey: Double] { cmuxTopProcessCPUTracker.cpuPercentages( for: samples, activeKeys: activeKeys, parentKeysByKey: parentKeysByKey, - sampledAtNanoseconds: sampledAtNanoseconds + sampledAtNanoseconds: sampledAtNanoseconds, + allowsPruning: allowsPruning ) } diff --git a/Sources/CmuxTopProcessEnumeration.swift b/Sources/CmuxTopProcessEnumeration.swift index e1cb65f89803..3bdf2b8426bc 100644 --- a/Sources/CmuxTopProcessEnumeration.swift +++ b/Sources/CmuxTopProcessEnumeration.swift @@ -4,9 +4,17 @@ import Foundation private nonisolated let cmuxTopPIDPathBufferSize = 4096 nonisolated extension CmuxTopProcessSnapshot { - static func allProcesses(includeProcessDetails: Bool, includeCMUXScope: Bool) -> [CmuxTopProcessInfo] { - let sampledProcesses = allBSDProcesses() - guard !sampledProcesses.isEmpty else { return [] } + static func allProcessesWithPerformanceProof( + includeProcessDetails: Bool, + includeCMUXScope: Bool + ) -> ( + processes: [CmuxTopProcessInfo], + proof: ProcessPerformanceCaptureProof, + isComplete: Bool + ) { + let enumeration = allBSDProcesses() + let sampledProcesses = enumeration.processes + guard !sampledProcesses.isEmpty else { return ([], .libproc, enumeration.isComplete) } var scopeKeyByPID: [Int: CmuxTopProcessScopeCacheKey] = [:] scopeKeyByPID.reserveCapacity(sampledProcesses.count) @@ -42,17 +50,28 @@ nonisolated extension CmuxTopProcessSnapshot { for: currentCPUSamples, activeKeys: activeScopeKeys, parentKeysByKey: parentScopeKeys, - sampledAtNanoseconds: sampledAtNanoseconds + sampledAtNanoseconds: sampledAtNanoseconds, + allowsPruning: enumeration.isComplete ) for index in processRecords.indices { guard let key = processRecords[index].cpuSampleKey, let cpuPercent = cpuPercentages[key] else { continue } processRecords[index].info.cpuPercent = cpuPercent } - if includeCMUXScope { + if includeCMUXScope, enumeration.isComplete { pruneCMUXScopeCache(activeKeys: activeScopeKeys) } - return processRecords.map(\.info) + return (processRecords.map(\.info), .libproc, enumeration.isComplete) + } + + static func allProcesses( + includeProcessDetails: Bool, + includeCMUXScope: Bool + ) -> [CmuxTopProcessInfo] { + allProcessesWithPerformanceProof( + includeProcessDetails: includeProcessDetails, + includeCMUXScope: includeCMUXScope + ).processes } static func deviceIdentifier(forTTYName ttyName: String) -> Int64? { @@ -154,18 +173,25 @@ nonisolated extension CmuxTopProcessSnapshot { ), cpuSampleKey) } - private static func allBSDProcesses() -> [proc_bsdinfo] { + private static func allBSDProcesses() -> (processes: [proc_bsdinfo], isComplete: Bool) { let pidStride = MemoryLayout.stride - func bsdInfos(from pids: [pid_t], count: Int) -> [proc_bsdinfo] { - pids.prefix(count).compactMap { pid in - guard pid > 0 else { return nil } - return bsdInfo(for: Int(pid)) + func bsdInfos(from pids: [pid_t], count: Int) -> (processes: [proc_bsdinfo], isComplete: Bool) { + var processes: [proc_bsdinfo] = [] + var isComplete = true + processes.reserveCapacity(count) + for pid in pids.prefix(count) where pid > 0 { + if let info = bsdInfo(for: Int(pid)) { + processes.append(info) + } else if processStillPresent(pid) { + isComplete = false + } } + return (processes, isComplete) } // proc_listallpids returns a PID count; the buffer size argument is bytes. let initialPIDCount = Int(proc_listallpids(nil, 0)) - guard initialPIDCount > 0 else { return [] } + guard initialPIDCount > 0 else { return ([], false) } var capacity = max(1, initialPIDCount + 32) var lastPIDs: [pid_t] = [] var lastCount = 0 @@ -174,8 +200,10 @@ nonisolated extension CmuxTopProcessSnapshot { let returnedCount = pids.withUnsafeMutableBufferPointer { buffer in proc_listallpids(buffer.baseAddress, Int32(buffer.count * pidStride)) } - guard returnedCount >= 0 else { - return lastCount > 0 ? bsdInfos(from: lastPIDs, count: lastCount) : [] + guard returnedCount > 0 else { + guard lastCount > 0 else { return ([], false) } + let partial = bsdInfos(from: lastPIDs, count: lastCount) + return (partial.processes, false) } let returnedPIDCount = Int(returnedCount) let count = min(pids.count, returnedPIDCount) @@ -188,14 +216,41 @@ nonisolated extension CmuxTopProcessSnapshot { } capacity = max(pids.count * 2, returnedPIDCount + 32) } - return lastCount > 0 ? bsdInfos(from: lastPIDs, count: lastCount) : [] + guard lastCount > 0 else { return ([], false) } + let partial = bsdInfos(from: lastPIDs, count: lastCount) + return (partial.processes, false) } private static func bsdInfo(for pid: Int) -> proc_bsdinfo? { var info = proc_bsdinfo() let expectedSize = MemoryLayout.stride let size = proc_pidinfo(pid_t(pid), PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize)) - return size == expectedSize ? info : nil + if size == expectedSize { return info } + guard let process = kinfoProc(for: pid) else { return nil } + return bsdInfo(from: process) + } + + private static func bsdInfo(from process: kinfo_proc) -> proc_bsdinfo { + var info = proc_bsdinfo() + info.pbi_pid = UInt32(bitPattern: process.kp_proc.p_pid) + info.pbi_ppid = UInt32(bitPattern: process.kp_eproc.e_ppid) + info.pbi_pgid = UInt32(bitPattern: process.kp_eproc.e_pgid) + info.pbi_start_tvsec = UInt64(max(0, process.kp_proc.p_un.__p_starttime.tv_sec)) + info.pbi_start_tvusec = UInt64(max(0, process.kp_proc.p_un.__p_starttime.tv_usec)) + info.e_tdev = UInt32(bitPattern: process.kp_eproc.e_tdev) + info.e_tpgid = UInt32(bitPattern: process.kp_eproc.e_tpgid) + withUnsafeBytes(of: process.kp_proc.p_comm) { source in + withUnsafeMutableBytes(of: &info.pbi_comm) { destination in + destination.copyBytes(from: source.prefix(destination.count)) + } + } + return info + } + + private static func processStillPresent(_ pid: pid_t) -> Bool { + errno = 0 + if kill(pid, 0) == 0 { return true } + return errno == EPERM } private static func taskInfo(for pid: Int) -> proc_taskinfo? { diff --git a/Sources/CmuxTopProcessSnapshotCache.swift b/Sources/CmuxTopProcessSnapshotCache.swift index 226577fcc2e3..0602612899ab 100644 --- a/Sources/CmuxTopProcessSnapshotCache.swift +++ b/Sources/CmuxTopProcessSnapshotCache.swift @@ -1,79 +1,378 @@ import Foundation import os -private nonisolated struct CmuxTopProcessSnapshotCacheState { - var snapshot: CmuxTopProcessSnapshot? - var includeProcessDetails = false - var includeCMUXScope = true +nonisolated struct CmuxTopProcessSnapshotRequirements: OptionSet, Sendable { + let rawValue: UInt8 + + static let processDetails = Self(rawValue: 1 << 0) + static let cmuxScope = Self(rawValue: 1 << 1) + static let basic: Self = [] } -// libproc snapshots are a short-lived platform bridge shared by the CLI, socket, -// and Task Manager paths; keep the cache here so ownership stays with capture(). -private nonisolated let cmuxTopProcessSnapshotCache = OSAllocatedUnfairLock( - initialState: CmuxTopProcessSnapshotCacheState() -) +actor CmuxTopProcessSnapshotStore { + typealias Now = @Sendable () async -> Date + typealias Capture = @Sendable (CmuxTopProcessSnapshotRequirements) async -> CmuxTopProcessSnapshot + typealias ProvenancedCapture = @Sendable (CmuxTopProcessSnapshotRequirements) async -> ( + snapshot: CmuxTopProcessSnapshot, + proof: ProcessPerformanceCaptureProof + ) -nonisolated extension CmuxTopProcessSnapshot { - static func captureCached( - includeProcessDetails: Bool = false, - includeCMUXScope: Bool = true, - maximumAge: TimeInterval - ) -> CmuxTopProcessSnapshot { - let now = Date() - if let cached = cmuxTopProcessSnapshotCache.withLock({ state -> CmuxTopProcessSnapshot? in - guard let snapshot = state.snapshot, - Self.cachedSnapshotDetailsSatisfy( - state.includeProcessDetails, - requested: includeProcessDetails - ), - Self.cachedSnapshotCMUXScopeSatisfies( - state.includeCMUXScope, - requested: includeCMUXScope - ), - now.timeIntervalSince(snapshot.sampledAt) <= maximumAge else { - return nil + static let shared = CmuxTopProcessSnapshotStore(captureWithProof: { requirements in + CmuxTopProcessSnapshot.captureWithPerformanceProof( + includeProcessDetails: requirements.contains(.processDetails), + includeCMUXScope: requirements.contains(.cmuxScope) + ) + }) + + private struct CachedSnapshot { + let snapshot: CmuxTopProcessSnapshot + let requirements: CmuxTopProcessSnapshotRequirements + let storedAt: Date + let generation: UInt64 + let metricsToken: ProcessPerformanceMetricToken + } + + private struct InFlightCapture { + let id: UInt64 + let requirements: CmuxTopProcessSnapshotRequirements + let task: Task<( + snapshot: CmuxTopProcessSnapshot, + proof: ProcessPerformanceCaptureProof + ), Never> + let metricsToken: ProcessPerformanceMetricToken + } + + private struct PerformanceExercise { + let gate: ProcessPerformanceExerciseGate + var generation: UInt64? + var secondaryJoins = 0 + var proof: ProcessPerformanceCaptureProof? + } + + private let now: Now + private let capture: ProvenancedCapture + private let metrics: ProcessPerformanceMetrics + private var cached: CachedSnapshot? + private var inFlight: InFlightCapture? + private var nextCaptureID: UInt64 = 0 + private var performanceExercise: PerformanceExercise? + + init( + now: @escaping Now = { Date() }, + capture: @escaping Capture, + metrics: ProcessPerformanceMetrics = .shared + ) { + self.now = now + self.capture = { requirements in + (await capture(requirements), .libproc) + } + self.metrics = metrics + } + + init( + now: @escaping Now = { Date() }, + captureWithProof: @escaping ProvenancedCapture, + metrics: ProcessPerformanceMetrics = .shared + ) { + self.now = now + self.capture = captureWithProof + self.metrics = metrics + } + + func snapshot( + requirements: CmuxTopProcessSnapshotRequirements, + maximumAge: TimeInterval, + consumer: ProcessSnapshotConsumer = .unspecified + ) async -> CmuxTopProcessSnapshot { + if let exercise = performanceExercise, + consumer != .performanceExercisePrimary, + consumer != .performanceExerciseSecondary { + await exercise.gate.waitUntilFinished() + return await snapshot( + requirements: requirements, + maximumAge: maximumAge, + consumer: consumer + ) + } + metrics.recordProcessSnapshotRequest(consumer: consumer) + let requestedAt = await now() + if let cached = validCachedSnapshot( + requirements: requirements, + maximumAge: maximumAge, + now: requestedAt + ) { + metrics.recordProcessSnapshotReuse( + consumer: consumer, + generation: cached.generation, + source: .cache, + token: cached.metricsToken + ) + return cached.snapshot + } + + while true { + if let inFlight { + if consumer == .performanceExerciseSecondary, + performanceExercise?.generation == inFlight.id { + performanceExercise?.secondaryJoins += 1 + await performanceExercise?.gate.recordJoin() + } + let captureResult = await inFlight.task.value + await finishCapture(inFlight, captureResult: captureResult) + if inFlight.requirements.isSuperset(of: requirements) { + metrics.recordProcessSnapshotReuse( + consumer: consumer, + generation: inFlight.id, + source: .inFlight, + token: inFlight.metricsToken + ) + return captureResult.snapshot + } + let now = await now() + if let cached = validCachedSnapshot( + requirements: requirements, + maximumAge: maximumAge, + now: now + ) { + metrics.recordProcessSnapshotReuse( + consumer: consumer, + generation: cached.generation, + source: .cache, + token: cached.metricsToken + ) + return cached.snapshot + } + continue } - return snapshot - }) { - return cached + + nextCaptureID &+= 1 + let id = nextCaptureID + let capture = self.capture + let exerciseGate = consumer == .performanceExercisePrimary + ? performanceExercise?.gate + : nil + if exerciseGate != nil { + performanceExercise?.generation = id + await exerciseGate?.recordGeneration(id) + } + let metricsToken = metrics.processSnapshotCaptureStarted( + generation: id, + requirementsRawValue: requirements.rawValue + ) + let task = Task.detached(priority: .utility) { + let result = await capture(requirements) + if let exerciseGate { + await exerciseGate.waitForCaptureRelease() + } + return result + } + let started = InFlightCapture( + id: id, + requirements: requirements, + task: task, + metricsToken: metricsToken + ) + inFlight = started + let captureResult = await task.value + await finishCapture(started, captureResult: captureResult) + return captureResult.snapshot } + } - let snapshot = capture( - includeProcessDetails: includeProcessDetails, - includeCMUXScope: includeCMUXScope - ) - return cmuxTopProcessSnapshotCache.withLock { state in - let storeTime = Date() - if let cached = state.snapshot, - Self.cachedSnapshotDetailsSatisfy( - state.includeProcessDetails, - requested: includeProcessDetails - ), - Self.cachedSnapshotCMUXScopeSatisfies( - state.includeCMUXScope, - requested: includeCMUXScope - ), - storeTime.timeIntervalSince(cached.sampledAt) <= maximumAge { - return cached + /// Forces real production snapshot requests through one fresh generation. + /// The barrier holds only the diagnostic capture result so every secondary + /// request reaches the normal in-flight sharing branch before completion. + func performanceMetricsExercise(requestCount: Int) async -> ( + measurementEpoch: UInt64, + generation: UInt64, + processCount: Int, + proof: ProcessPerformanceCaptureProof, + sharedSnapshotIdentity: Bool + )? { + while let active = inFlight { + let captureResult = await active.task.value + await finishCapture(active, captureResult: captureResult) + } + cached = nil + let startingMetrics = metrics.snapshot() + guard startingMetrics.enabled, startingMetrics.measurementEpoch > 0 else { + return nil + } + + let boundedRequestCount = min(max(requestCount, 2), 8) + let gate = ProcessPerformanceExerciseGate() + performanceExercise = PerformanceExercise(gate: gate) + return await withTaskCancellationHandler { + await runPerformanceMetricsExercise( + requestCount: boundedRequestCount, + measurementEpoch: startingMetrics.measurementEpoch, + gate: gate + ) + } onCancel: { + Task { await self.cancelPerformanceExercise(gate) } + } + } + + private func runPerformanceMetricsExercise( + requestCount: Int, + measurementEpoch: UInt64, + gate: ProcessPerformanceExerciseGate + ) async -> ( + measurementEpoch: UInt64, + generation: UInt64, + processCount: Int, + proof: ProcessPerformanceCaptureProof, + sharedSnapshotIdentity: Bool + )? { + let requirements: CmuxTopProcessSnapshotRequirements = [.processDetails, .cmuxScope] + let primary = Task { + await self.snapshot( + requirements: requirements, + maximumAge: 0, + consumer: .performanceExercisePrimary + ) + } + guard await gate.waitForGeneration() != nil else { + await cancelPerformanceExercise(gate) + return nil + } + let secondaries = (1.. 0, + completedMetrics.enabled, + completedMetrics.measurementEpoch == measurementEpoch, + let generationMetrics = completedMetrics.generations[generation], + generationMetrics.started == 1, + generationMetrics.completed == 1, + generationMetrics.processCount == processCount else { + return nil + } + return ( + measurementEpoch, + generation, + processCount, + proof, + sharedSnapshotIdentity + ) } - private static func cachedSnapshotDetailsSatisfy( - _ cachedIncludesProcessDetails: Bool, - requested: Bool - ) -> Bool { - cachedIncludesProcessDetails || !requested + private func cancelPerformanceExercise(_ gate: ProcessPerformanceExerciseGate) async { + guard performanceExercise?.gate === gate else { return } + await gate.releaseCapture() + await gate.finish() + performanceExercise = nil } - private static func cachedSnapshotCMUXScopeSatisfies( - _ cachedIncludesCMUXScope: Bool, - requested: Bool - ) -> Bool { - cachedIncludesCMUXScope || !requested + private func finishCapture( + _ completed: InFlightCapture, + captureResult: ( + snapshot: CmuxTopProcessSnapshot, + proof: ProcessPerformanceCaptureProof + ) + ) async { + let storedAt = await now() + guard inFlight?.id == completed.id else { return } + if performanceExercise?.generation == completed.id { + performanceExercise?.proof = captureResult.proof + } + cached = CachedSnapshot( + snapshot: captureResult.snapshot, + requirements: completed.requirements, + storedAt: storedAt, + generation: completed.id, + metricsToken: completed.metricsToken + ) + inFlight = nil + metrics.processSnapshotCaptureCompleted( + completed.metricsToken, + generation: completed.id, + processCount: captureResult.snapshot.processesByPID.count, + proof: captureResult.proof + ) + } + + private func validCachedSnapshot( + requirements: CmuxTopProcessSnapshotRequirements, + maximumAge: TimeInterval, + now: Date + ) -> CachedSnapshot? { + guard let cached, + cached.requirements.isSuperset(of: requirements), + now.timeIntervalSince(cached.storedAt) <= max(0, maximumAge) else { + return nil + } + return cached + } +} + +nonisolated extension CmuxTopProcessSnapshot { + /// Measured escape hatch for lifecycle and compatibility code that cannot await + /// ``CmuxTopProcessSnapshotStore``. Live and periodic consumers must use the actor. + static func captureSynchronouslyForCompatibility( + includeProcessDetails: Bool = false, + includeCMUXScope: Bool = true, + metrics: ProcessPerformanceMetrics = .shared, + captureWithProof: (Bool, Bool) -> ( + snapshot: CmuxTopProcessSnapshot, + proof: ProcessPerformanceCaptureProof + ) = { + includeProcessDetails, includeCMUXScope in + CmuxTopProcessSnapshot.captureWithPerformanceProof( + includeProcessDetails: includeProcessDetails, + includeCMUXScope: includeCMUXScope + ) + } + ) -> CmuxTopProcessSnapshot { + let generation = metrics.nextSynchronousCaptureGeneration() + var requirements = CmuxTopProcessSnapshotRequirements.basic + if includeProcessDetails { requirements.insert(.processDetails) } + if includeCMUXScope { requirements.insert(.cmuxScope) } + let token = metrics.processSnapshotCaptureStarted( + generation: generation, + requirementsRawValue: requirements.rawValue + ) + let capture = captureWithProof(includeProcessDetails, includeCMUXScope) + metrics.processSnapshotCaptureCompleted( + token, + generation: generation, + processCount: capture.snapshot.processesByPID.count, + proof: capture.proof + ) + return capture.snapshot } } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 0ae5faaf8701..fad28db5808a 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -131,6 +131,7 @@ nonisolated struct CmuxTopProcessScope: Sendable, Equatable { nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { let sampledAt: Date + let isComplete: Bool private let includesProcessDetails: Bool private let includesCMUXScope: Bool let processesByPID: [Int: CmuxTopProcessInfo] @@ -143,14 +144,36 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { includeProcessDetails: Bool = false, includeCMUXScope: Bool = true ) -> CmuxTopProcessSnapshot { - CmuxTopProcessSnapshot( - processes: allProcesses( - includeProcessDetails: includeProcessDetails, - includeCMUXScope: includeCMUXScope - ), + let capture = allProcessesWithPerformanceProof( + includeProcessDetails: includeProcessDetails, + includeCMUXScope: includeCMUXScope + ) + return CmuxTopProcessSnapshot( + processes: capture.processes, sampledAt: Date(), includesProcessDetails: includeProcessDetails, - includesCMUXScope: includeCMUXScope + includesCMUXScope: includeCMUXScope, + isComplete: capture.isComplete + ) + } + + static func captureWithPerformanceProof( + includeProcessDetails: Bool = false, + includeCMUXScope: Bool = true + ) -> (snapshot: CmuxTopProcessSnapshot, proof: ProcessPerformanceCaptureProof) { + let capture = allProcessesWithPerformanceProof( + includeProcessDetails: includeProcessDetails, + includeCMUXScope: includeCMUXScope + ) + return ( + CmuxTopProcessSnapshot( + processes: capture.processes, + sampledAt: Date(), + includesProcessDetails: includeProcessDetails, + includesCMUXScope: includeCMUXScope, + isComplete: capture.isComplete + ), + capture.proof ) } @@ -158,9 +181,11 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { processes: [CmuxTopProcessInfo], sampledAt: Date, includesProcessDetails: Bool, - includesCMUXScope: Bool = true + includesCMUXScope: Bool = true, + isComplete: Bool = true ) { self.sampledAt = sampledAt + self.isComplete = isComplete self.includesProcessDetails = includesProcessDetails self.includesCMUXScope = includesCMUXScope var processMap: [Int: CmuxTopProcessInfo] = [:] @@ -204,7 +229,8 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { "resident_memory_sources": residentMemorySourceNames, "resident_memory_fallback_source": CmuxTopProcessMemorySource.rusageResidentSize.rawValue, "process_details": includesProcessDetails, - "cmux_scope": includesCMUXScope + "cmux_scope": includesCMUXScope, + "complete": isComplete ] } @@ -275,50 +301,14 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { var result: Set = includeRoot && processesByPID[rootPID] != nil ? [rootPID] : [] var visited: Set = [] var stack = childrenByParentPID[rootPID] ?? [] - stack.append(contentsOf: Self.listedChildPIDs(parentPID: rootPID)) while let pid = stack.popLast() { guard visited.insert(pid).inserted else { continue } guard result.insert(pid).inserted else { continue } stack.append(contentsOf: childrenByParentPID[pid] ?? []) - stack.append(contentsOf: Self.listedChildPIDs(parentPID: pid)) } return result } - private static func listedChildPIDs(parentPID: Int) -> [Int] { - guard parentPID > 0 else { return [] } - - let pidStride = MemoryLayout.stride - var capacity = 16 - var lastChildren: [Int] = [] - for _ in 0..<4 { - var pids = Array(repeating: pid_t(), count: capacity) - let returnedCount = pids.withUnsafeMutableBufferPointer { buffer in - proc_listchildpids( - pid_t(parentPID), - buffer.baseAddress, - Int32(buffer.count * pidStride) - ) - } - guard returnedCount >= 0 else { - return lastChildren - } - - let count = min(pids.count, Int(returnedCount)) - lastChildren = pids - .prefix(count) - .compactMap { pid in - let intPID = Int(pid) - return intPID > 0 ? intPID : nil - } - if Int(returnedCount) < pids.count { - return lastChildren - } - capacity = max(pids.count * 2, Int(returnedCount) + 16) - } - return lastChildren - } - func summaryPayload(for pids: Set, rootPIDs: Set = []) -> [String: Any] { summary(for: pids, rootPIDs: rootPIDs).payload() } diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index 33421c07f9cd..3f76d39532fd 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -39,10 +39,10 @@ nonisolated enum CmuxTopProcessScopeProbeResult: Equatable { case unavailable } -// CmuxTopProcessSnapshot.capture is intentionally synchronous because it backs -// both async task-manager sampling and sync v2 system.top socket handling. Keep -// this tiny lock isolated to dictionary reads/writes; procargs/sysctl work must -// happen outside the critical section. +// The raw enumerator is synchronous underneath the actor-owned process snapshot +// store and the measured lifecycle compatibility seam. Keep this tiny lock +// isolated to dictionary reads/writes; procargs/sysctl work must happen outside +// the critical section. private nonisolated let cmuxTopScopeCache = OSAllocatedUnfairLock( initialState: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue]() ) @@ -91,12 +91,11 @@ nonisolated extension CmuxTopProcessSnapshot { // is eventually attributed. The key is pruned to live pids each // capture, so a recycled pid gets a fresh key. // - // capture() runs concurrently (async task-manager sampling and sync - // system.top socket handling), and the probe happened outside the - // lock. Never let a stale negative from an older capture clobber a - // positive scope a newer capture already discovered for the same - // process: if we probed nil but a positive entry now exists, keep and - // return it. + // A measured lifecycle fallback can overlap the actor-owned capture, + // and the probe happened outside the lock. Never let a stale negative + // from an older capture clobber a positive scope a newer capture already + // discovered for the same process: if we probed nil but a positive entry + // now exists, keep and return it. return cmuxTopScopeCache.withLock { cache -> CmuxTopProcessScope? in if scope == nil, let existing = cache[cacheKey], let existingScope = existing.scope { return existingScope @@ -265,7 +264,7 @@ nonisolated extension CmuxTopProcessSnapshot { return nil } - private static func kinfoProc(for pid: Int) -> kinfo_proc? { + static func kinfoProc(for pid: Int) -> kinfo_proc? { guard pid > 0, pid <= Int(Int32.max) else { return nil } var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)] diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6bf92b4020eb..f1866227a5de 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2572,10 +2572,6 @@ struct ContentView: View { reconcileMountedWorkspaceIds() }) - view = AnyView(view.onReceive(tabManager.$mountedBackgroundWorkspaceLoadIds) { _ in - reconcileMountedWorkspaceIds() - }) - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard tabManager.shouldScheduleRawTitleRefresh(forWorkspaceId: GhosttyTitleChange(notification: notification)?.tabId) else { return } scheduleTitlebarTextRefresh() @@ -3188,9 +3184,7 @@ struct ContentView: View { let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] - let pinnedIds = handoffPinnedIds - .union(tabManager.mountedBackgroundWorkspaceLoadIds) - .union(tabManager.debugPinnedWorkspaceLoadIds) + let pinnedIds = handoffPinnedIds.union(tabManager.debugPinnedWorkspaceLoadIds) let isCycleHot = tabManager.isWorkspaceCycleHot let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty let baseMaxMounted = shouldKeepHandoffPair diff --git a/Sources/FocusHistoryMenuInvalidator.swift b/Sources/FocusHistoryMenuInvalidator.swift index 02cee9d4d071..bc086704d714 100644 --- a/Sources/FocusHistoryMenuInvalidator.swift +++ b/Sources/FocusHistoryMenuInvalidator.swift @@ -1,22 +1,56 @@ import AppKit +import CmuxFoundation import SwiftUI @MainActor final class FocusHistoryMenuInvalidator: ObservableObject { + typealias DeadlineScheduler = LatestWinsBatcher.Scheduler + @Published private(set) var revision: UInt64 = 0 private let center: NotificationCenter private var observers: [NSObjectProtocol] = [] + private let batcher: LatestWinsBatcher + + convenience init(center: NotificationCenter = .default) { + self.init( + center: center, + batcher: LatestWinsBatcher( + quietDelay: 0.04, + maximumDelay: 0.12 + ) + ) + } + + /// Internal scheduler seam used by behavior tests to drive the real + /// notification path without wall-clock sleeps. + convenience init( + center: NotificationCenter, + scheduler: @escaping DeadlineScheduler + ) { + self.init( + center: center, + batcher: LatestWinsBatcher( + quietDelay: 0.04, + maximumDelay: 0.12, + scheduler: scheduler + ) + ) + } - init(center: NotificationCenter = .default) { + private init( + center: NotificationCenter, + batcher: LatestWinsBatcher + ) { self.center = center + self.batcher = batcher observers.append(center.addObserver( forName: .tabManagerFocusHistoryRevisionDidChange, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in - self?.revision &+= 1 + MainActor.assumeIsolated { + self?.invalidate() } }) observers.append(center.addObserver( @@ -24,8 +58,8 @@ final class FocusHistoryMenuInvalidator: ObservableObject { object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in - self?.revision &+= 1 + MainActor.assumeIsolated { + self?.invalidate() } }) } @@ -35,4 +69,10 @@ final class FocusHistoryMenuInvalidator: ObservableObject { center.removeObserver(observer) } } + + private func invalidate() { + batcher.submit(true, for: true) { [weak self] _ in + self?.revision &+= 1 + } + } } diff --git a/Sources/GhosttyNSView+RendererProfiling.swift b/Sources/GhosttyNSView+RendererProfiling.swift new file mode 100644 index 000000000000..a2efe8bae698 --- /dev/null +++ b/Sources/GhosttyNSView+RendererProfiling.swift @@ -0,0 +1,80 @@ +import Foundation +import CmuxTerminal +import CmuxTerminalCore +import AppKit +import QuartzCore +import os + +extension GhosttyNSView { + func enqueueRenderedFrameUpdate() { + let profilingEnabled = rendererProfilingSignposts.isEnabled + let deliveryPolicy = TerminalRenderedFrameDeliveryPolicy( + renderDemandActive: GhosttyApp.renderedFrameNotificationDemand.isActive + ) + guard deliveryPolicy.shouldEnqueue(profilingEnabled: profilingEnabled) else { return } + + _renderedFrameLock.lock() + rendererProfilingCoalescedUpdateCount += 1 + let needsSchedule = !_renderedFrameFlushScheduled + if needsSchedule { + _renderedFrameFlushScheduled = true + } + _renderedFrameLock.unlock() + + guard needsSchedule else { return } + if profilingEnabled, let metadata = rendererProfilingMetadata(coalescedUpdateCount: 1) { + let profilingState = rendererProfilingSignposts.beginUpdate(metadata) + _renderedFrameLock.lock() + rendererProfilingUpdateState = profilingState + _renderedFrameLock.unlock() + } + DispatchQueue.main.async { [weak self] in + self?.flushRenderedFrameUpdate() + } + } + + private func flushRenderedFrameUpdate() { + _renderedFrameLock.lock() + _renderedFrameFlushScheduled = false + let coalescedUpdateCount = rendererProfilingCoalescedUpdateCount + rendererProfilingCoalescedUpdateCount = 0 + let profilingState = rendererProfilingUpdateState + rendererProfilingUpdateState = nil + _renderedFrameLock.unlock() + + if let metadata = rendererProfilingMetadata(coalescedUpdateCount: coalescedUpdateCount) { + rendererProfilingSignposts.endUpdate(profilingState, metadata) + } + guard GhosttyApp.renderedFrameNotificationDemand.isActive else { return } + NotificationCenter.default.post( + name: .ghosttyDidRenderFrame, + object: self + ) + } + + private func rendererProfilingMetadata( + coalescedUpdateCount: Int + ) -> TerminalRendererProfilingMetadata? { + guard let terminalSurface else { return nil } + return TerminalRendererProfilingMetadata( + identity: terminalSurface.rendererProfilingIdentity, + visible: isVisibleInUI, + focused: desiredFocus, + wakeReason: .terminalOutput, + coalescedUpdateCount: coalescedUpdateCount, + dirtyRowCount: nil, + fullRedraw: nil + ) + } + + func updateRendererProfilingState( + wakeReason: TerminalRendererProfilingWakeReason + ) { + (layer as? GhosttyMetalLayer)?.setProfilingState( + identity: terminalSurface?.rendererProfilingIdentity, + visible: isVisibleInUI, + focused: desiredFocus, + wakeReason: wakeReason + ) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e4cd62df46ea..e85050ef7487 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3362,6 +3362,13 @@ extension TerminalSurface { // MARK: - Ghostty Surface View class GhosttyNSView: NSView, NSUserInterfaceValidations { + private struct PendingInputDemandKeyEvent { + let surfaceId: UUID + let event: NSEvent + } + + private static let maxPendingInputDemandKeyEvents = 256 + private static let focusDebugEnabled: Bool = { if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" { return true @@ -3429,8 +3436,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var _pendingScrollbar: GhosttyScrollbar? private var _scrollbarFlushScheduled = false private let _scrollbarLock = NSLock() - private var _renderedFrameFlushScheduled = false - private let _renderedFrameLock = NSLock() + var _renderedFrameFlushScheduled = false + let _renderedFrameLock = NSLock() + let rendererProfilingSignposts = TerminalRendererProfilingSignposts() + var rendererProfilingCoalescedUpdateCount = 0 + var rendererProfilingUpdateState: OSSignpostIntervalState? nonisolated let selectionAccessibilitySignal = TerminalSelectionAccessibilitySignal() private var selectionAccessibilityNotifier: TerminalSelectionAccessibilityNotifier? var cellSize: CGSize = .zero @@ -3536,33 +3546,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } - func enqueueRenderedFrameUpdate() { - guard GhosttyApp.renderedFrameNotificationDemand.isActive else { return } - _renderedFrameLock.lock() - let needsSchedule = !_renderedFrameFlushScheduled - if needsSchedule { - _renderedFrameFlushScheduled = true - } - _renderedFrameLock.unlock() - - guard needsSchedule else { return } - DispatchQueue.main.async { [weak self] in - self?.flushRenderedFrameUpdate() - } - } - - private func flushRenderedFrameUpdate() { - _renderedFrameLock.lock() - _renderedFrameFlushScheduled = false - _renderedFrameLock.unlock() - - guard GhosttyApp.renderedFrameNotificationDemand.isActive else { return } - NotificationCenter.default.post( - name: .ghosttyDidRenderFrame, - object: self - ) - } - var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID? @@ -3628,6 +3611,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? + private var runtimeSurfaceReadyObserver: NSObjectProtocol? + private var pendingInputDemandKeyEvents: [PendingInputDemandKeyEvent] = [] + private var pendingInputDemandKeyEventFlushScheduled = false private var lastScrollEventTime: CFTimeInterval = 0 private let scrollSpeedAccumulator = TerminalScrollSpeedAccumulator() private var visibleInUI: Bool = true @@ -3665,9 +3651,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Visibility is used for focus gating. Explicit portal visibility transitions // also drive Ghostty occlusion so hidden workspace/split surfaces pause and // queue a redraw when they become visible again. - fileprivate var isVisibleInUI: Bool { visibleInUI } + var isVisibleInUI: Bool { visibleInUI } fileprivate func setVisibleInUI(_ visible: Bool) { visibleInUI = visible + updateRendererProfilingState(wakeReason: .visibility) } override init(frame frameRect: NSRect) { @@ -3703,10 +3690,26 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { layer?.masksToBounds = true setupKeyboardCopyModeCursorOverlay() installEventMonitor() + installRuntimeSurfaceReadyObserver() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) } + private func installRuntimeSurfaceReadyObserver() { + runtimeSurfaceReadyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let readySurface = notification.object as? TerminalSurface, + readySurface === self.terminalSurface else { + return + } + self.schedulePendingInputDemandKeyEventFlush() + } + } + private func setupKeyboardCopyModeCursorOverlay() { keyboardCopyModeCursorOverlayView.wantsLayer = true keyboardCopyModeCursorOverlayView.layer?.backgroundColor = NSColor.controlAccentColor @@ -3855,12 +3858,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let isSameSurface = terminalSurface === surface let isAlreadyAttached = surface.isAttached(to: self) if !isSameSurface { + pendingInputDemandKeyEvents.removeAll(keepingCapacity: false) appliedColorScheme = nil // Reset any OSC 22 mouse shape carried over from the previous surface. updateGhosttyMouseShape(GHOSTTY_MOUSE_SHAPE_TEXT) } terminalSurface = surface tabId = surface.tabId + updateRendererProfilingState(wakeReason: .terminalOutput) if !isAlreadyAttached { surface.attachToView(self) } else { @@ -4204,7 +4209,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { /// reallocate Metal drawables when the pixel size is unchanged. @discardableResult func forceRefreshSurface() -> Bool { - updateSurfaceSize() + updateRendererProfilingState(wakeReason: .explicitRefresh) + return updateSurfaceSize() } private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { @@ -4280,6 +4286,52 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) #endif } + private func enqueueInputDemandKeyEvent(_ event: NSEvent, reason: String) { + guard let surfaceId = terminalSurface?.id else { return } + guard pendingInputDemandKeyEvents.count < Self.maxPendingInputDemandKeyEvents else { +#if DEBUG + cmuxDebugLog( + "focus.input_recovery.reject surface=\(surfaceId.uuidString.prefix(5)) " + + "reason=queueFull type=\(event.type.rawValue)" + ) +#endif + requestInputRecoveryAfterSurfaceMiss(reason: reason) + return + } + pendingInputDemandKeyEvents.append( + PendingInputDemandKeyEvent(surfaceId: surfaceId, event: event) + ) + requestInputRecoveryAfterSurfaceMiss(reason: reason) + } + + private func schedulePendingInputDemandKeyEventFlush() { + guard !pendingInputDemandKeyEventFlushScheduled else { return } + pendingInputDemandKeyEventFlushScheduled = true + Task { @MainActor [weak self] in + guard let self else { return } + self.pendingInputDemandKeyEventFlushScheduled = false + self.flushPendingInputDemandKeyEventsIfReady() + } + } + + private func flushPendingInputDemandKeyEventsIfReady() { + guard surface != nil, let surfaceId = terminalSurface?.id else { return } + let queued = pendingInputDemandKeyEvents + pendingInputDemandKeyEvents.removeAll(keepingCapacity: false) + for pending in queued where pending.surfaceId == surfaceId { + switch pending.event.type { + case .keyDown: + keyDown(with: pending.event) + case .keyUp: + keyUp(with: pending.event) + case .flagsChanged: + flagsChanged(with: pending.event) + default: + break + } + } + } + @discardableResult func prepareSurfaceForPaste(reason: String) -> Bool { terminalSurface?.didReceiveExplicitInput() @@ -5335,6 +5387,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { in: window ) == false { desiredFocus = false + updateRendererProfilingState(wakeReason: .focus) terminalSurface.recordExternalFocusState(false) terminalSurface.hostedView.cancelSuppressedFirstResponderFocusReapply() #if DEBUG @@ -5346,6 +5399,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // If we become first responder before the ghostty surface exists (e.g. during // split/tab creation while the surface is still being created), record the desired focus. desiredFocus = true + updateRendererProfilingState(wakeReason: .focus) // During programmatic splits, SwiftUI reparents the old NSView which triggers // becomeFirstResponder. Suppress onFocus + ghostty_surface_set_focus to prevent @@ -5428,6 +5482,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if result { imeConsumedKeyUps.removeAll() desiredFocus = false + updateRendererProfilingState(wakeReason: .focus) terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply() terminalSurface?.recordExternalFocusState(false) } @@ -5681,11 +5736,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime #endif guard let surface = ensureSurfaceReadyForInput() else { - requestInputRecoveryAfterSurfaceMiss(reason: "keyDown.missingSurface") + enqueueInputDemandKeyEvent(event, reason: "keyDown.missingSurface") #if DEBUG ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 #endif - super.keyDown(with: event) return } recordDirectAgentHibernationTerminalInput() @@ -6111,7 +6165,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func keyUp(with event: NSEvent) { guard let surface = ensureSurfaceReadyForInput() else { - super.keyUp(with: event) + enqueueInputDemandKeyEvent(event, reason: "keyUp.missingSurface") return } if event.keyCode != 53 { @@ -6144,7 +6198,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func flagsChanged(with event: NSEvent) { guard let surface = ensureSurfaceReadyForInput() else { - super.flagsChanged(with: event) + enqueueInputDemandKeyEvent(event, reason: "flagsChanged.missingSurface") return } @@ -7507,6 +7561,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } + if let runtimeSurfaceReadyObserver { + NotificationCenter.default.removeObserver(runtimeSurfaceReadyObserver) + } if let trackingArea { removeTrackingArea(trackingArea) } @@ -10364,6 +10421,7 @@ final class GhosttySurfaceScrollView: NSView { func yieldTerminalSurfaceFocusForForeignResponder(reason: String) { surfaceView.desiredFocus = false + surfaceView.updateRendererProfilingState(wakeReason: .focus) pendingSuppressedFirstResponderFocusReapply = false guard let terminalSurface = surfaceView.terminalSurface else { return } terminalSurface.setFocus(false) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift index 4a5cc5138915..834d54046256 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+LiveAgentPID.swift @@ -20,12 +20,14 @@ extension AgentChatSessionRegistry { surfaceID: String, kind: ChatAgentKind, matchingSessionIDs expectedSessionIDs: Set, - allowUnidentifiedFallback: Bool = false - ) -> Int? { + allowUnidentifiedFallback: Bool = false, + snapshotStore: CmuxTopProcessSnapshotStore = .shared + ) async -> Int? { guard !expectedSessionIDs.isEmpty else { return nil } - let snapshot = CmuxTopProcessSnapshot.capture( - includeProcessDetails: true, - includeCMUXScope: true + let snapshot = await snapshotStore.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 0, + consumer: .sharedLiveAgentIndex ) return liveAgentPID( in: snapshot, diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift index 2c5a4527386e..8ad39926f013 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ObserveScan.swift @@ -162,7 +162,7 @@ extension AgentChatSessionRegistry { observeLastStartedAt = Date() let id = UUID() let scanTask = Task.detached { - Self.scanObservedAgentSessions(onlySurfaceIDs: scope.surfaceIDs) + await Self.scanObservedAgentSessions(onlySurfaceIDs: scope.surfaceIDs) } let task = Task { @MainActor [weak self] in let observed = await withTaskCancellationHandler { @@ -185,11 +185,12 @@ extension AgentChatSessionRegistry { /// surface, identity resolved without hooks. private nonisolated static func scanObservedAgentSessions( onlySurfaceIDs surfaceIDs: Set? = nil - ) -> [ObservedAgentSession] { + ) async -> [ObservedAgentSession] { guard !Task.isCancelled else { return [] } - let snapshot = CmuxTopProcessSnapshot.capture( - includeProcessDetails: true, - includeCMUXScope: true + let snapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 1, + consumer: .sharedLiveAgentIndex ) guard !Task.isCancelled else { return [] } return scanObservedAgentSessions( @@ -291,110 +292,6 @@ extension AgentChatSessionRegistry { return candidateBySessionID.values.map(\.session).sorted { $0.pid < $1.pid } } - nonisolated static func allowsLaunchKindEnvironment( - for process: CmuxTopProcessInfo, - rootPIDs: Set, - arguments: [String]? - ) -> Bool { - if rootPIDs.contains(process.pid) { - return true - } - guard process.isTerminalForegroundProcessGroup, - process.processGroupID == process.pid, - let arguments else { - return false - } - if CmuxTaskManagerCodingAgentDefinition.matchingDefinition( - processName: process.name, - processPath: process.path, - arguments: arguments, - environment: [:] - ) != nil { - return true - } - return arguments.dropFirst().contains { argument in - normalizedObserverValue(argument)?.contains("/.cmux-agent-wrapper/") == true - } - } - - nonisolated static func codingAgentDefinition( - for process: CmuxTopProcessInfo, - allowLaunchKindEnvironment: Bool, - processArgumentsAndEnvironment: (Int) -> CmuxTopProcessArguments? - ) -> CmuxTaskManagerCodingAgentDefinition? { - let shouldReadDetails = CmuxTaskManagerCodingAgentDefinition.shouldReadArguments( - processName: process.name, - processPath: process.path - ) - if let direct = authoritativeCodingAgentDefinition( - processName: process.name, - processPath: process.path, - arguments: [], - environment: [:], - allowLaunchKindEnvironment: false - ) { - return direct - } - if !shouldReadDetails { return nil } - guard let details = processArgumentsAndEnvironment(process.pid) else { - return nil - } - return authoritativeCodingAgentDefinition( - processName: process.name, - processPath: process.path, - arguments: details.arguments, - environment: details.environment, - allowLaunchKindEnvironment: allowLaunchKindEnvironment - ) - } - - private nonisolated static func authoritativeCodingAgentDefinition( - processName: String, - processPath: String?, - arguments: [String], - environment: [String: String], - allowLaunchKindEnvironment: Bool - ) -> CmuxTaskManagerCodingAgentDefinition? { - let definitions = CmuxTaskManagerCodingAgentDefinition.builtIns - if allowLaunchKindEnvironment, - let launchKind = normalizedObserverValue(environment["CMUX_AGENT_LAUNCH_KIND"]), - let def = definitions.first(where: { $0.launchKinds.contains(launchKind) }) { - return def - } - let basenames = Set([processName, processPath, arguments.first].compactMap(observerBasename)) - if let def = definitions.first(where: { def in basenames.contains { def.directBasenames.contains($0) } }) { - return def - } - guard let path = normalizedObserverValue(processPath) else { return nil } - return definitions.first { def in - def.argumentNeedles.contains { needle in - guard needle.hasSuffix("/"), - let normalizedNeedle = normalizedObserverValue(needle) else { return false } - return path.contains(normalizedNeedle) - } - } - } - - private nonisolated static func observerBasename(_ value: String?) -> String? { - normalizedObserverValue(value.map { ($0 as NSString).lastPathComponent }) - } - - private nonisolated static func normalizedObserverValue(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed?.isEmpty == false ? trimmed : nil - } - - private nonisolated static func observedWorkingDirectory(_ environment: [String: String]?) -> String? { - guard let environment else { return nil } - for key in ["CMUX_AGENT_LAUNCH_CWD", "PWD"] { - if let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty { - return value - } - } - return nil - } - private func endedPendingClaudeSessionHasHistoryIdentity(_ record: AgentChatSessionRecord) -> Bool { record.transcriptPath != nil || record.hookStoreSessionID != nil } @@ -409,91 +306,4 @@ extension AgentChatSessionRegistry { return session.sampledAt >= (current.endedAt ?? current.lastActivityAt) } - nonisolated static func sessionIDFromArguments(_ arguments: [String]) -> String? { - var index = 0 - while index < arguments.count { - let arg = arguments[index] - if ["--session-id", "--resume", "-r"].contains(arg), - index + 1 < arguments.count, - let id = sessionIDFromOptionValue(arguments[index + 1]) { - return id - } - for prefix in ["--session-id=", "--resume=", "-r="] where arg.hasPrefix(prefix) { - if let id = sessionIDFromOptionValue(String(arg.dropFirst(prefix.count))) { - return id - } - } - index += 1 - } - return nil - } - - private nonisolated static func containsExplicitSessionOption(_ arguments: [String]) -> Bool { - arguments.contains { argument in - argument == "--session-id" - || argument == "--resume" - || argument == "-r" - || argument.hasPrefix("--session-id=") - || argument.hasPrefix("--resume=") - || argument.hasPrefix("-r=") - } - } - nonisolated static func containsClaudeForkSessionOption(_ arguments: [String]) -> Bool { - arguments.contains { argument in - let value = argument.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return value == "--fork-session" || value.hasPrefix("--fork-session=") - } - } - - private nonisolated static func sessionIDFromOptionValue(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.hasPrefix("-") else { return nil } - return firstUUIDLike(in: trimmed) - } - /// libproc: the path of a `~/.codex/sessions/**/rollout-*.jsonl` the process - /// holds open (codex keeps its rollout open for writing), or nil. - nonisolated static func openCodexRolloutPath(pid: Int) -> String? { - let listSize = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, nil, 0) - guard listSize > 0 else { return nil } - let count = Int(listSize) / MemoryLayout.stride - guard count > 0 else { return nil } - var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: count) - let used = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, &fds, listSize) - guard used > 0 else { return nil } - let actual = Int(used) / MemoryLayout.stride - for index in 0...size) - ) - guard size > 0 else { continue } - let path = withUnsafeBytes(of: &info.pvip.vip_path) { raw -> String in - guard let base = raw.baseAddress else { return "" } - return String(cString: base.assumingMemoryBound(to: CChar.self)) - } - if path.hasSuffix(".jsonl"), path.contains("/.codex/sessions/") { - return path - } - } - return nil - } - - private nonisolated static let uuidLikeRegex = try? NSRegularExpression( - pattern: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" - ) - - /// The first UUID-shaped substring (matches both standard UUIDs and codex's - /// UUIDv7 rollout ids), or nil. - nonisolated static func firstUUIDLike(in string: String) -> String? { - guard let regex = uuidLikeRegex else { return nil } - let range = NSRange(string.startIndex..., in: string) - guard let match = regex.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range, in: string) else { return nil } - return String(string[matchRange]) - } } diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ProcessClassification.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ProcessClassification.swift new file mode 100644 index 000000000000..b284fe7d6ce4 --- /dev/null +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry+ProcessClassification.swift @@ -0,0 +1,197 @@ +import CMUXAgentLaunch +import CmuxAgentChat +import Foundation + +extension AgentChatSessionRegistry { + nonisolated static func allowsLaunchKindEnvironment( + for process: CmuxTopProcessInfo, + rootPIDs: Set, + arguments: [String]? + ) -> Bool { + if rootPIDs.contains(process.pid) { + return true + } + guard process.isTerminalForegroundProcessGroup, + process.processGroupID == process.pid, + let arguments else { + return false + } + if CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: process.name, + processPath: process.path, + arguments: arguments, + environment: [:] + ) != nil { + return true + } + return arguments.dropFirst().contains { argument in + normalizedObserverValue(argument)?.contains("/.cmux-agent-wrapper/") == true + } + } + + nonisolated static func codingAgentDefinition( + for process: CmuxTopProcessInfo, + allowLaunchKindEnvironment: Bool, + processArgumentsAndEnvironment: (Int) -> CmuxTopProcessArguments? + ) -> CmuxTaskManagerCodingAgentDefinition? { + let shouldReadDetails = CmuxTaskManagerCodingAgentDefinition.shouldReadArguments( + processName: process.name, + processPath: process.path + ) + if let direct = authoritativeCodingAgentDefinition( + processName: process.name, + processPath: process.path, + arguments: [], + environment: [:], + allowLaunchKindEnvironment: false + ) { + return direct + } + if !shouldReadDetails { return nil } + guard let details = processArgumentsAndEnvironment(process.pid) else { + return nil + } + return authoritativeCodingAgentDefinition( + processName: process.name, + processPath: process.path, + arguments: details.arguments, + environment: details.environment, + allowLaunchKindEnvironment: allowLaunchKindEnvironment + ) + } + + private nonisolated static func authoritativeCodingAgentDefinition( + processName: String, + processPath: String?, + arguments: [String], + environment: [String: String], + allowLaunchKindEnvironment: Bool + ) -> CmuxTaskManagerCodingAgentDefinition? { + let definitions = CmuxTaskManagerCodingAgentDefinition.builtIns + if allowLaunchKindEnvironment, + let launchKind = normalizedObserverValue(environment["CMUX_AGENT_LAUNCH_KIND"]), + let def = definitions.first(where: { $0.launchKinds.contains(launchKind) }) { + return def + } + let basenames = Set([processName, processPath, arguments.first].compactMap(observerBasename)) + if let def = definitions.first(where: { def in basenames.contains { def.directBasenames.contains($0) } }) { + return def + } + guard let path = normalizedObserverValue(processPath) else { return nil } + return definitions.first { def in + def.argumentNeedles.contains { needle in + guard needle.hasSuffix("/"), + let normalizedNeedle = normalizedObserverValue(needle) else { return false } + return path.contains(normalizedNeedle) + } + } + } + + private nonisolated static func observerBasename(_ value: String?) -> String? { + normalizedObserverValue(value.map { ($0 as NSString).lastPathComponent }) + } + + private nonisolated static func normalizedObserverValue(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed?.isEmpty == false ? trimmed : nil + } + + nonisolated static func observedWorkingDirectory(_ environment: [String: String]?) -> String? { + guard let environment else { return nil } + for key in ["CMUX_AGENT_LAUNCH_CWD", "PWD"] { + if let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty { + return value + } + } + return nil + } + + nonisolated static func sessionIDFromArguments(_ arguments: [String]) -> String? { + var index = 0 + while index < arguments.count { + let arg = arguments[index] + if ["--session-id", "--resume", "-r"].contains(arg), + index + 1 < arguments.count, + let id = sessionIDFromOptionValue(arguments[index + 1]) { + return id + } + for prefix in ["--session-id=", "--resume=", "-r="] where arg.hasPrefix(prefix) { + if let id = sessionIDFromOptionValue(String(arg.dropFirst(prefix.count))) { + return id + } + } + index += 1 + } + return nil + } + + nonisolated static func containsExplicitSessionOption(_ arguments: [String]) -> Bool { + arguments.contains { argument in + argument == "--session-id" + || argument == "--resume" + || argument == "-r" + || argument.hasPrefix("--session-id=") + || argument.hasPrefix("--resume=") + || argument.hasPrefix("-r=") + } + } + nonisolated static func containsClaudeForkSessionOption(_ arguments: [String]) -> Bool { + arguments.contains { argument in + let value = argument.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return value == "--fork-session" || value.hasPrefix("--fork-session=") + } + } + + private nonisolated static func sessionIDFromOptionValue(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.hasPrefix("-") else { return nil } + return firstUUIDLike(in: trimmed) + } + /// libproc: the path of a `~/.codex/sessions/**/rollout-*.jsonl` the process + /// holds open (codex keeps its rollout open for writing), or nil. + nonisolated static func openCodexRolloutPath(pid: Int) -> String? { + let listSize = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, nil, 0) + guard listSize > 0 else { return nil } + let count = Int(listSize) / MemoryLayout.stride + guard count > 0 else { return nil } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: count) + let used = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, &fds, listSize) + guard used > 0 else { return nil } + let actual = Int(used) / MemoryLayout.stride + for index in 0...size) + ) + guard size > 0 else { continue } + let path = withUnsafeBytes(of: &info.pvip.vip_path) { raw -> String in + guard let base = raw.baseAddress else { return "" } + return String(cString: base.assumingMemoryBound(to: CChar.self)) + } + if path.hasSuffix(".jsonl"), path.contains("/.codex/sessions/") { + return path + } + } + return nil + } + + private nonisolated static let uuidLikeRegex = try? NSRegularExpression( + pattern: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + ) + + /// The first UUID-shaped substring (matches both standard UUIDs and codex's + /// UUIDv7 rollout ids), or nil. + nonisolated static func firstUUIDLike(in string: String) -> String? { + guard let regex = uuidLikeRegex else { return nil } + let range = NSRange(string.startIndex..., in: string) + guard let match = regex.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range, in: string) else { return nil } + return String(string[matchRange]) + } +} diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index db729eb3f093..38eea37b963a 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -190,7 +190,7 @@ final class AgentChatSessionRegistry { let kind = record.agentKind let expectedSessionIDs = Set([record.sessionID, record.hookStoreLookupSessionID]) Task.detached { [weak self] in - let livePID = Self.liveAgentPID( + let livePID = await Self.liveAgentPID( surfaceID: surfaceID, kind: kind, matchingSessionIDs: expectedSessionIDs, diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 0df766db1206..e6fd80ddc587 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1,5 +1,6 @@ import CMUXMobileCore import CmuxAuthRuntime +import CmuxFoundation import CmuxSettings import CmuxTerminalCore import CryptoKit @@ -27,8 +28,15 @@ extension Notification.Name { } private enum MobileHostEventSubscriptionTracker { + private static let terminalOutputTopics: Set = [ + "terminal.bytes", + "terminal.render_grid", + ] private static let lock = NSLock() private nonisolated(unsafe) static var topicCounts: [String: Int] = [:] + /// Mirrors whether either terminal-output topic has subscribers so Ghostty's + /// per-chunk output callback never needs to acquire the tracker lock. + private static let terminalOutputDemand = AtomicBooleanGate(false) static func hasSubscribers(topic: String) -> Bool { lock.lock() @@ -36,6 +44,11 @@ private enum MobileHostEventSubscriptionTracker { return (topicCounts[topic] ?? 0) > 0 } + @inline(__always) + static func hasTerminalOutputSubscribers() -> Bool { + terminalOutputDemand.loadAcquire() + } + static func replace(previousTopics: Set?, nextTopics: Set?) { let changedTopics = updateCounts(previousTopics: previousTopics, nextTopics: nextTopics) guard !changedTopics.isEmpty else { return } @@ -66,6 +79,10 @@ private enum MobileHostEventSubscriptionTracker { topicCounts[topic] = (topicCounts[topic] ?? 0) + 1 } + terminalOutputDemand.storeRelease( + terminalOutputTopics.contains { (topicCounts[$0] ?? 0) > 0 } + ) + for topic in allTopics { let wasActive = (before[topic] ?? 0) > 0 let isActive = (topicCounts[topic] ?? 0) > 0 @@ -79,6 +96,7 @@ private enum MobileHostEventSubscriptionTracker { static func reset() { lock.lock() topicCounts.removeAll() + terminalOutputDemand.storeRelease(false) lock.unlock() NotificationCenter.default.post( name: .mobileHostEventSubscriptionsDidChange, @@ -471,6 +489,11 @@ final class MobileHostService { MobileHostEventSubscriptionTracker.hasSubscribers(topic: topic) } + /// Lock-free aggregate demand check for Ghostty's terminal-output hot path. + nonisolated static func hasTerminalOutputSubscribers() -> Bool { + MobileHostEventSubscriptionTracker.hasTerminalOutputSubscribers() + } + /// User-default key for the opt-in Mac-side iOS pairing listener. nonisolated static let listeningEnabledDefaultsKey = SettingCatalog().mobile.iOSPairingHost.userDefaultsKey @@ -1579,6 +1602,10 @@ extension MobileHostService { MobileHostEventSubscriptionTracker.hasSubscribers(topic: topic) } + nonisolated static func debugHasTerminalOutputSubscribersForTesting() -> Bool { + MobileHostEventSubscriptionTracker.hasTerminalOutputSubscribers() + } + nonisolated static func debugResetEventSubscriptionsForTesting() { MobileHostEventSubscriptionTracker.resetForTesting() } diff --git a/Sources/Mobile/MobileTerminalByteTee.swift b/Sources/Mobile/MobileTerminalByteTee.swift index 18f0ce95d95f..3aaccf8fd87f 100644 --- a/Sources/Mobile/MobileTerminalByteTee.swift +++ b/Sources/Mobile/MobileTerminalByteTee.swift @@ -61,18 +61,15 @@ final class MobileTerminalByteTee { // Hot path: this runs on the Ghostty PTY/IO read thread for *every* // surface, including normal desktop use with no phone attached. Bail // before any allocation or main-actor hop when no mobile client wants - // these bytes. The check is an O(1) dictionary read of the single - // subscription source of truth (`MobileHostEventSubscriptionTracker`), - // the same accessor `MobileTerminalRenderObserver` already uses; it is - // not a new lock, and its only writers are the rare subscribe / - // unsubscribe RPCs, so the IO thread never meaningfully contends. We - // gate on both topics because `publishFromMain` is load-bearing for - // the render-grid stream too: it advances `seq` (read as `stateSeq`) - // and calls `noteTerminalBytes` to schedule the post-parse tick. - guard - MobileHostService.hasEventSubscribers(topic: "terminal.bytes") - || MobileHostService.hasEventSubscribers(topic: "terminal.render_grid") - else { + // these bytes. Subscription mutations publish one process-wide atomic + // aggregate for both terminal-output topics. An acquire load pairs + // with subscription mutation's release store, so the first chunk + // after activation cannot use the relaxed disabled-path semantics. + // This per-chunk path never acquires the subscription tracker's lock. We gate on both + // topics because `publishFromMain` is load-bearing for the render-grid + // stream too: it advances `seq` (read as `stateSeq`) and calls + // `noteTerminalBytes` to schedule the post-parse tick. + guard MobileHostService.hasTerminalOutputSubscribers() else { return } guard let base = bytes.baseAddress, bytes.count > 0 else { return } diff --git a/Sources/Mobile/MobileWorkspaceListObserver.swift b/Sources/Mobile/MobileWorkspaceListObserver.swift index 96dd34ad9297..a911b4a7c171 100644 --- a/Sources/Mobile/MobileWorkspaceListObserver.swift +++ b/Sources/Mobile/MobileWorkspaceListObserver.swift @@ -1,10 +1,27 @@ import Combine +import CmuxFoundation import CmuxWorkspaces import Foundation import OSLog private let mobileWorkspaceObserverLog = Logger(subsystem: "dev.cmux", category: "mobile-workspace-observer") +private enum MobileWorkspaceInvalidation: Hashable { + case workspaceGraph + case workspace(UUID) + case preview + case summary + + var metricKind: MobileWorkspaceObserverInvalidationMetricKind { + switch self { + case .workspaceGraph: .workspaceGraph + case .workspace: .workspace + case .preview: .preview + case .summary: .summary + } + } +} + /// Watches `TabManager.tabs` (and each workspace's panels publisher) and emits /// `workspace.updated` to subscribed mobile clients whenever the iOS-facing /// shape of the workspace list materially changes. Replaces per-RPC emit hooks @@ -13,6 +30,8 @@ private let mobileWorkspaceObserverLog = Logger(subsystem: "dev.cmux", category: /// the `@Published` source of truth instead of trying to catch every caller. @MainActor final class MobileWorkspaceListObserver { + typealias DeadlineScheduler = LatestWinsBatcher.Scheduler + private weak var tabManager: TabManager? /// The app-global notification store, source of each workspace's last-activity /// preview line. Weak because the store is app-global and outlives this @@ -26,16 +45,59 @@ final class MobileWorkspaceListObserver { private var unreadIndicatorsCancellable: AnyCancellable? private var perWorkspaceCancellables: [UUID: AnyCancellable] = [:] private var lastSummaryHash: Int = 0 - /// Throttle window with `latest: true`. First event in a burst emits - /// immediately (iPhone gets the change in milliseconds), subsequent - /// events within the window collapse to one trailing emit carrying the - /// final state. So a single action is instant; a burst caps at ~1 emit - /// per 80 ms. Hash-diff suppresses no-op rebroadcasts. - private let throttleMilliseconds: Int = 80 + private var workspaceSummaryHashes: [UUID: Int] = [:] + private var lastPreviewSignatures: [UUID: Int] = [:] + /// Every publisher funnels through one cross-workspace batch. A quiet burst + /// drains after 50 ms; sustained churn drains at least every 160 ms. The + /// keyed latest-wins batch also records whether subscriptions must be + /// reconciled before hashing the final source-of-truth state. + private let invalidationBatcher: LatestWinsBatcher + private let emitWorkspaceUpdated: @MainActor () -> Void + + convenience init(tabManager: TabManager, notificationStore: TerminalNotificationStore? = nil) { + self.init( + tabManager: tabManager, + notificationStore: notificationStore, + invalidationBatcher: LatestWinsBatcher( + quietDelay: 0.05, + maximumDelay: 0.16 + ), + emitWorkspaceUpdated: { + MobileHostService.shared.emitEvent(topic: "workspace.updated", payload: [:]) + } + ) + } + + /// Internal seams used by behavior tests to drive the real observer and + /// inspect its boundary effects without sleeping or replacing its model. + convenience init( + tabManager: TabManager, + notificationStore: TerminalNotificationStore? = nil, + scheduler: @escaping DeadlineScheduler, + emitWorkspaceUpdated: @escaping @MainActor () -> Void + ) { + self.init( + tabManager: tabManager, + notificationStore: notificationStore, + invalidationBatcher: LatestWinsBatcher( + quietDelay: 0.05, + maximumDelay: 0.16, + scheduler: scheduler + ), + emitWorkspaceUpdated: emitWorkspaceUpdated + ) + } - init(tabManager: TabManager, notificationStore: TerminalNotificationStore? = nil) { + private init( + tabManager: TabManager, + notificationStore: TerminalNotificationStore?, + invalidationBatcher: LatestWinsBatcher, + emitWorkspaceUpdated: @escaping @MainActor () -> Void + ) { self.tabManager = tabManager self.notificationStore = notificationStore + self.invalidationBatcher = invalidationBatcher + self.emitWorkspaceUpdated = emitWorkspaceUpdated #if DEBUG cmuxDebugLog("mobile.observer init tabs=\(tabManager.tabs.count)") #endif @@ -46,32 +108,40 @@ final class MobileWorkspaceListObserver { // Initial snapshot. Every observer's first emit is unconditional so // freshly-paired clients see the current state without waiting for // the first mutation. - let initial = Self.summaryHash( + let graphToken = MobileWorkspaceObserverMetrics.shared.fullGraphRebuildStarted() + let initialPreviewSignatures = currentPreviewSignatures(for: tabManager.tabs) + lastPreviewSignatures = initialPreviewSignatures + workspaceSummaryHashes = Self.workspaceSummaryHashes( for: tabManager.tabs, + previewSignatures: initialPreviewSignatures + ) + MobileWorkspaceObserverMetrics.shared.operationCompleted( + graphToken, + workspacesRehashed: tabManager.tabs.count + ) + let initial = Self.summaryHash( + workspaceHashes: workspaceSummaryHashes, + orderedWorkspaceIDs: tabManager.tabs.map(\.id), groups: tabManager.workspaceGroups, - selectedTabID: tabManager.selectedTabId, - previewSignatures: currentPreviewSignatures(for: tabManager.tabs) + selectedTabID: tabManager.selectedTabId ) lastSummaryHash = initial emitIfNeeded(force: true) tabsCancellable = tabManager.tabsPublisher - .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) - .sink { [weak self] tabs in + .sink { [weak self] _ in guard let self else { return } #if DEBUG - cmuxDebugLog("mobile.observer tabs sink fired count=\(tabs.count)") + cmuxDebugLog("mobile.observer tabs sink fired count=\(self.tabManager?.tabs.count ?? 0)") #endif - self.refreshPerWorkspaceSubscriptions(tabs: tabs) - self.emitIfNeeded(force: false) + self.scheduleInvalidation(.workspaceGraph) } // Selection changes (Mac user clicks a different sidebar tab) need // to push to iPhone too. iPhone's selectedWorkspaceID drives which // terminal it displays. selectionCancellable = tabManager.selectedTabIdPublisher - .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) .sink { [weak self] _ in - self?.emitIfNeeded(force: false) + self?.scheduleInvalidation(.summary) } // Group structure (order, name, collapse/pin, anchor, membership) is // iOS-facing: the phone renders collapsible group sections. A pure @@ -80,9 +150,8 @@ final class MobileWorkspaceListObserver { // collapsed from the Mac (or from the phone's own collapse RPC, which is // authoritative + re-fetch based, not optimistic). groupsCancellable = tabManager.workspaceGroupsPublisher - .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) .sink { [weak self] _ in - self?.emitIfNeeded(force: false) + self?.scheduleInvalidation(.summary) } // Last-activity preview lines come from the notification store, which is // not part of the TabManager graph. A new notification (or a cleared one) @@ -95,15 +164,12 @@ final class MobileWorkspaceListObserver { // Ordering invariant: `@Published` emits from `willSet`, but every sink // here reads the store's post-`didSet` state (latestNotification / // unread indexes) rather than the emitted value. That is safe because - // `throttle(for:scheduler: RunLoop.main)` always hops through the run - // loop, so delivery happens after the assignment (and its `didSet` - // index rebuild) completes; it never fires synchronously from - // `willSet`. The pre-existing `$tabs` / `$selectedTabId` sinks rely on - // the same property. + // the shared latest-wins batch always hops through an asynchronous + // deadline, so delivery happens after the assignment (and its `didSet` + // index rebuild) completes; it never reads synchronously from `willSet`. notificationsCancellable = notificationStore?.$notifications - .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) .sink { [weak self] _ in - self?.emitIfNeeded(force: false) + self?.scheduleInvalidation(.preview) } // Workspace-level unread indicators (manual mark-unread, panel-derived, // session-restored) live in their own published sets, not in @@ -115,9 +181,8 @@ final class MobileWorkspaceListObserver { notificationStore.$panelDerivedUnreadWorkspaceIds.map { _ in () }.eraseToAnyPublisher(), notificationStore.$restoredUnreadWorkspaceIds.map { _ in () }.eraseToAnyPublisher() ) - .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) .sink { [weak self] _ in - self?.emitIfNeeded(force: false) + self?.scheduleInvalidation(.preview) } } @@ -140,6 +205,8 @@ final class MobileWorkspaceListObserver { for tabs: [Workspace], notificationStore: TerminalNotificationStore? ) -> [UUID: Int] { + let metricsToken = MobileWorkspaceObserverMetrics.shared.previewSignaturesStarted() + defer { MobileWorkspaceObserverMetrics.shared.operationCompleted(metricsToken) } let signpost = MobileWorkspaceObserverSignposts.begin("mobile-workspace-preview-signatures", "workspaces=\(tabs.count) hasStore=\(notificationStore != nil)"); defer { MobileWorkspaceObserverSignposts.end(signpost) } guard let notificationStore else { return [:] } var signatures: [UUID: Int] = [:] @@ -202,34 +269,109 @@ final class MobileWorkspaceListObserver { workspace.paneLayoutVersionPublisher.map { _ in () }.eraseToAnyPublisher(), ] let merged = Publishers.MergeMany(publishers) - .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) + let workspaceID = workspace.id perWorkspaceCancellables[workspace.id] = merged.sink { [weak self] _ in - self?.emitIfNeeded(force: false) + self?.scheduleInvalidation(.workspace(workspaceID)) + } + } + } + + private func scheduleInvalidation(_ invalidation: MobileWorkspaceInvalidation) { + MobileWorkspaceObserverMetrics.shared.recordInvalidationSubmitted(invalidation.metricKind) + invalidationBatcher.submit(true, for: invalidation) { [weak self] invalidations in + guard let self, let tabManager = self.tabManager else { return } + let batchToken = MobileWorkspaceObserverMetrics.shared.batchDrainStarted( + invalidationCount: invalidations.count + ) + defer { MobileWorkspaceObserverMetrics.shared.operationCompleted(batchToken) } + if invalidations[.workspaceGraph] == true { + self.refreshPerWorkspaceSubscriptions(tabs: tabManager.tabs) } + self.refreshSummaryCache( + for: Set(invalidations.keys), + tabs: tabManager.tabs + ) + self.emitIfNeeded(force: false) } } + private func refreshSummaryCache( + for invalidations: Set, + tabs: [Workspace] + ) { + if invalidations.contains(.workspaceGraph) { + let metricsToken = MobileWorkspaceObserverMetrics.shared.fullGraphRebuildStarted() + let previews = currentPreviewSignatures(for: tabs) + lastPreviewSignatures = previews + workspaceSummaryHashes = Self.workspaceSummaryHashes( + for: tabs, + previewSignatures: previews + ) + MobileWorkspaceObserverMetrics.shared.operationCompleted( + metricsToken, + workspacesRehashed: tabs.count + ) + return + } + + let metricsToken = MobileWorkspaceObserverMetrics.shared.incrementalRefreshStarted() + + let workspacesByID = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + var workspaceIDs = Set(invalidations.compactMap { invalidation -> UUID? in + guard case .workspace(let id) = invalidation else { return nil } + return id + }) + + if invalidations.contains(.preview) { + let previews = currentPreviewSignatures(for: tabs) + let allPreviewIDs = Set(lastPreviewSignatures.keys).union(previews.keys) + workspaceIDs.formUnion(allPreviewIDs.filter { + lastPreviewSignatures[$0] != previews[$0] + }) + lastPreviewSignatures = previews + } + + var rehashedWorkspaceCount = 0 + for id in workspaceIDs { + guard let workspace = workspacesByID[id] else { + workspaceSummaryHashes.removeValue(forKey: id) + continue + } + rehashedWorkspaceCount += 1 + workspaceSummaryHashes[id] = Self.workspaceSummaryHash( + for: workspace, + previewSignature: lastPreviewSignatures[id] + ) + } + MobileWorkspaceObserverMetrics.shared.operationCompleted( + metricsToken, + workspacesRehashed: rehashedWorkspaceCount + ) + } + private func emitIfNeeded(force: Bool) { let signpost = MobileWorkspaceObserverSignposts.begin("mobile-workspace-emit-if-needed", "force=\(force)"); defer { MobileWorkspaceObserverSignposts.end(signpost) } guard let tabManager else { return } let hash = Self.summaryHash( - for: tabManager.tabs, + workspaceHashes: workspaceSummaryHashes, + orderedWorkspaceIDs: tabManager.tabs.map(\.id), groups: tabManager.workspaceGroups, - selectedTabID: tabManager.selectedTabId, - previewSignatures: currentPreviewSignatures(for: tabManager.tabs) + selectedTabID: tabManager.selectedTabId ) if !force, hash == lastSummaryHash { + MobileWorkspaceObserverMetrics.shared.recordSkip() #if DEBUG cmuxDebugLog("mobile.observer skip: hash unchanged=\(hash) tabs=\(tabManager.tabs.count)") #endif return } lastSummaryHash = hash + MobileWorkspaceObserverMetrics.shared.recordEmit() mobileWorkspaceObserverLog.debug("emitting workspace.updated (hash=\(hash, privacy: .public))") #if DEBUG cmuxDebugLog("mobile.observer EMIT workspace.updated hash=\(hash) tabs=\(tabManager.tabs.count) force=\(force)") #endif - MobileHostService.shared.emitEvent(topic: "workspace.updated", payload: [:]) + emitWorkspaceUpdated() } /// Stable hash of the iOS-facing shape: workspace ids + titles + their @@ -248,14 +390,16 @@ final class MobileWorkspaceListObserver { /// (or a cleared one) re-emits to the phone, which renders the preview + relative /// time. Workspaces with no notification are simply absent from the map. private static func summaryHash( - for tabs: [Workspace], + workspaceHashes: [UUID: Int], + orderedWorkspaceIDs: [UUID], groups: [WorkspaceGroup], - selectedTabID: UUID?, - previewSignatures: [UUID: Int] + selectedTabID: UUID? ) -> Int { - let signpost = MobileWorkspaceObserverSignposts.begin("mobile-workspace-summary-hash", "workspaces=\(tabs.count) groups=\(groups.count) previews=\(previewSignatures.count) selected=\(selectedTabID.map { String($0.uuidString.prefix(5)) } ?? "nil")"); defer { MobileWorkspaceObserverSignposts.end(signpost) } + let metricsToken = MobileWorkspaceObserverMetrics.shared.summaryHashStarted() + defer { MobileWorkspaceObserverMetrics.shared.operationCompleted(metricsToken) } + let signpost = MobileWorkspaceObserverSignposts.begin("mobile-workspace-summary-hash", "workspaces=\(orderedWorkspaceIDs.count) groups=\(groups.count) selected=\(selectedTabID.map { String($0.uuidString.prefix(5)) } ?? "nil")"); defer { MobileWorkspaceObserverSignposts.end(signpost) } var hasher = Hasher() - hasher.combine(tabs.count) + hasher.combine(orderedWorkspaceIDs.count) hasher.combine(selectedTabID) // Group sections are iOS-facing. Hash group order + the fields the phone // renders (name, collapse, pin, anchor) so a pure collapse/expand, rename, @@ -269,40 +413,61 @@ final class MobileWorkspaceListObserver { hasher.combine(group.isPinned) hasher.combine(group.anchorWorkspaceId) } - for workspace in tabs { - hasher.combine(workspace.id) - hasher.combine(workspace.title) - hasher.combine(workspace.isPinned) - // Group membership is iOS-facing (the phone nests members under the - // group header), and a pure move-into/out-of-group need not change the - // panel set or title, so hash it here. - hasher.combine(workspace.groupId) - // Last-activity preview line + timestamp shown on each row. Sourced - // from the notification store (not the TabManager graph), so it is - // folded in here as a precomputed signature. - hasher.combine(previewSignatures[workspace.id]) - // Spatial order is significant: hash the ordered id sequence so a - // reorder of the same panel set changes the hash. - let panelIDs = workspace.orderedPanelIds - hasher.combine(panelIDs) - for id in panelIDs { - hasher.combine(workspace.panelTitle(panelId: id)) - hasher.combine(workspace.reportedPanelDirectory(panelId: id)) - } - hasher.combine(workspace.presentedCurrentDirectory) - // Todo mutations change the list-facing shape; without these the - // hash-diff would suppress the re-emit the publishers above fire. - hasher.combine(workspace.todoState.statusOverride) - hasher.combine(workspace.todoState.checklist) - // Hash every panelDirectories entry (including ids not yet in - // `panels`) so a directory update is detected even before its panel - // registers. The ordered loop above already covers in-panel - // directories; this preserves the pre-existing behavior the mobile - // hash test relies on. - for id in workspace.panelDirectories.keys.sorted() { - hasher.combine(id) - hasher.combine(workspace.panelDirectories[id]) - } + for workspaceID in orderedWorkspaceIDs { + hasher.combine(workspaceID) + hasher.combine(workspaceHashes[workspaceID]) + } + return hasher.finalize() + } + + private static func workspaceSummaryHashes( + for tabs: [Workspace], + previewSignatures: [UUID: Int] + ) -> [UUID: Int] { + Dictionary(uniqueKeysWithValues: tabs.map { workspace in + (workspace.id, workspaceSummaryHash( + for: workspace, + previewSignature: previewSignatures[workspace.id] + )) + }) + } + + private static func workspaceSummaryHash( + for workspace: Workspace, + previewSignature: Int? + ) -> Int { + var hasher = Hasher() + hasher.combine(workspace.title) + hasher.combine(workspace.isPinned) + // Group membership is iOS-facing (the phone nests members under the + // group header), and a pure move-into/out-of-group need not change the + // panel set or title, so hash it here. + hasher.combine(workspace.groupId) + // Last-activity preview line + timestamp shown on each row. Sourced + // from the notification store (not the TabManager graph), so it is + // folded in here as a precomputed signature. + hasher.combine(previewSignature) + // Spatial order is significant: hash the ordered id sequence so a + // reorder of the same panel set changes the hash. + let panelIDs = workspace.orderedPanelIds + hasher.combine(panelIDs) + for id in panelIDs { + hasher.combine(workspace.panelTitle(panelId: id)) + hasher.combine(workspace.reportedPanelDirectory(panelId: id)) + } + hasher.combine(workspace.presentedCurrentDirectory) + // Todo mutations change the list-facing shape; without these the + // hash-diff would suppress the re-emit the publishers above fire. + hasher.combine(workspace.todoState.statusOverride) + hasher.combine(workspace.todoState.checklist) + // Hash every panelDirectories entry (including ids not yet in + // `panels`) so a directory update is detected even before its panel + // registers. The ordered loop above already covers in-panel + // directories; this preserves the pre-existing behavior the mobile + // hash test relies on. + for id in workspace.panelDirectories.keys.sorted() { + hasher.combine(id) + hasher.combine(workspace.panelDirectories[id]) } return hasher.finalize() } @@ -314,12 +479,16 @@ final class MobileWorkspaceListObserver { selectedTabID: UUID?, previewSignatures: [UUID: Int] = [:] ) -> Int { - summaryHash( + let workspaceHashes = workspaceSummaryHashes( for: tabs, - groups: groups, - selectedTabID: selectedTabID, previewSignatures: previewSignatures ) + return summaryHash( + workspaceHashes: workspaceHashes, + orderedWorkspaceIDs: tabs.map(\.id), + groups: groups, + selectedTabID: selectedTabID + ) } #endif } diff --git a/Sources/Mobile/MobileWorkspaceObserverMetrics.swift b/Sources/Mobile/MobileWorkspaceObserverMetrics.swift new file mode 100644 index 000000000000..1aff93154c19 --- /dev/null +++ b/Sources/Mobile/MobileWorkspaceObserverMetrics.swift @@ -0,0 +1,315 @@ +import CmuxFoundation +import Foundation +import os + +nonisolated enum MobileWorkspaceObserverInvalidationMetricKind: String, Sendable { + case workspaceGraph = "workspace_graph" + case workspace + case preview + case summary +} + +nonisolated struct MobileWorkspaceObserverDurationMetrics: Sendable, Equatable { + var count = 0 + var totalMilliseconds = 0.0 + var maximumMilliseconds = 0.0 + var lastMilliseconds = 0.0 + + var foundationObject: [String: Any] { + [ + "count": count, + "total": totalMilliseconds, + "max": maximumMilliseconds, + "last": lastMilliseconds, + ] + } +} + +nonisolated struct MobileWorkspaceObserverMetricsSnapshot: Sendable, Equatable { + let schemaVersion: Int + let enabled: Bool + let resetAtUnixMilliseconds: UInt64 + let invalidationsSubmitted: [String: Int] + let batchDrains: Int + let invalidationsDrained: Int + let workspacesRehashed: Int + let fullGraphRebuilds: Int + let emits: Int + let skips: Int + let batchDrainDuration: MobileWorkspaceObserverDurationMetrics + let fullGraphRebuildDuration: MobileWorkspaceObserverDurationMetrics + let incrementalRefreshDuration: MobileWorkspaceObserverDurationMetrics + let previewSignaturesDuration: MobileWorkspaceObserverDurationMetrics + let summaryHashDuration: MobileWorkspaceObserverDurationMetrics + + /// Property-list-safe representation for diagnostic RPCs. It contains only + /// aggregate counts and timings, never workspace identity or content. + var foundationObject: [String: Any] { + [ + "schema_version": schemaVersion, + "enabled": enabled, + "reset_at_unix_ms": NSNumber(value: resetAtUnixMilliseconds), + "invalidations_submitted": invalidationsSubmitted, + "batch_drains": batchDrains, + "invalidations_drained": invalidationsDrained, + "workspaces_rehashed": workspacesRehashed, + "full_graph_rebuilds": fullGraphRebuilds, + "emits": emits, + "skips": skips, + "duration_ms": [ + "batch_drain": batchDrainDuration.foundationObject, + "full_graph_rebuild": fullGraphRebuildDuration.foundationObject, + "incremental_refresh": incrementalRefreshDuration.foundationObject, + "preview_signatures": previewSignaturesDuration.foundationObject, + "summary_hash": summaryHashDuration.foundationObject, + ], + ] + } +} + +nonisolated struct MobileWorkspaceObserverMetricToken: Sendable, Equatable { + fileprivate enum Operation: Sendable, Equatable { + case batchDrain + case fullGraphRebuild + case incrementalRefresh + case previewSignatures + case summaryHash + } + + fileprivate let operation: Operation + fileprivate let epoch: UInt64 + fileprivate let startedAtNanoseconds: UInt64 +} + +/// Release-safe, process-wide proof counters for the mobile workspace observer. +/// The lock covers only fixed-size integer/timing updates and snapshots. No +/// workspace data or observer work executes while the lock is held. +nonisolated final class MobileWorkspaceObserverMetrics: @unchecked Sendable { + static let shared = MobileWorkspaceObserverMetrics() + + private struct State { + var epoch: UInt64 + var enabled: Bool + var resetAtUnixMilliseconds = MobileWorkspaceObserverMetrics.unixMilliseconds() + var workspaceGraphInvalidationsSubmitted = 0 + var workspaceInvalidationsSubmitted = 0 + var previewInvalidationsSubmitted = 0 + var summaryInvalidationsSubmitted = 0 + var batchDrains = 0 + var invalidationsDrained = 0 + var workspacesRehashed = 0 + var fullGraphRebuilds = 0 + var emits = 0 + var skips = 0 + var batchDrainDuration = MobileWorkspaceObserverDurationMetrics() + var fullGraphRebuildDuration = MobileWorkspaceObserverDurationMetrics() + var incrementalRefreshDuration = MobileWorkspaceObserverDurationMetrics() + var previewSignaturesDuration = MobileWorkspaceObserverDurationMetrics() + var summaryHashDuration = MobileWorkspaceObserverDurationMetrics() + + init(epoch: UInt64, enabled: Bool) { + self.epoch = epoch + self.enabled = enabled + } + } + + private let state: OSAllocatedUnfairLock + private let enabled: AtomicBooleanGate + private let monotonicNanoseconds: @Sendable () -> UInt64 + + init( + enabled: Bool = false, + monotonicNanoseconds: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + } + ) { + let epoch: UInt64 = enabled ? 1 : 0 + self.state = OSAllocatedUnfairLock( + initialState: State(epoch: epoch, enabled: enabled) + ) + self.enabled = AtomicBooleanGate(enabled) + self.monotonicNanoseconds = monotonicNanoseconds + } + + func reset(enable: Bool) { + state.withLock { state in + var nextEpoch = state.epoch &+ 1 + if nextEpoch == 0 { + nextEpoch = 1 + } + state = State(epoch: nextEpoch, enabled: enable) + } + enabled.storeRelease(enable) + } + + func disable() { + enabled.storeRelease(false) + state.withLock { state in + state.enabled = false + state.epoch &+= 1 + if state.epoch == 0 { + state.epoch = 1 + } + } + } + + func snapshot() -> MobileWorkspaceObserverMetricsSnapshot { + state.withLock { state in + MobileWorkspaceObserverMetricsSnapshot( + schemaVersion: 1, + enabled: state.enabled, + resetAtUnixMilliseconds: state.resetAtUnixMilliseconds, + invalidationsSubmitted: [ + MobileWorkspaceObserverInvalidationMetricKind.workspaceGraph.rawValue: + state.workspaceGraphInvalidationsSubmitted, + MobileWorkspaceObserverInvalidationMetricKind.workspace.rawValue: + state.workspaceInvalidationsSubmitted, + MobileWorkspaceObserverInvalidationMetricKind.preview.rawValue: + state.previewInvalidationsSubmitted, + MobileWorkspaceObserverInvalidationMetricKind.summary.rawValue: + state.summaryInvalidationsSubmitted, + ], + batchDrains: state.batchDrains, + invalidationsDrained: state.invalidationsDrained, + workspacesRehashed: state.workspacesRehashed, + fullGraphRebuilds: state.fullGraphRebuilds, + emits: state.emits, + skips: state.skips, + batchDrainDuration: state.batchDrainDuration, + fullGraphRebuildDuration: state.fullGraphRebuildDuration, + incrementalRefreshDuration: state.incrementalRefreshDuration, + previewSignaturesDuration: state.previewSignaturesDuration, + summaryHashDuration: state.summaryHashDuration + ) + } + } + + func recordInvalidationSubmitted(_ kind: MobileWorkspaceObserverInvalidationMetricKind) { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + switch kind { + case .workspaceGraph: + state.workspaceGraphInvalidationsSubmitted += 1 + case .workspace: + state.workspaceInvalidationsSubmitted += 1 + case .preview: + state.previewInvalidationsSubmitted += 1 + case .summary: + state.summaryInvalidationsSubmitted += 1 + } + } + } + + func batchDrainStarted(invalidationCount: Int) -> MobileWorkspaceObserverMetricToken? { + guard enabled.loadRelaxed() else { return nil } + return state.withLock { state -> MobileWorkspaceObserverMetricToken? in + guard state.enabled else { return nil } + state.batchDrains += 1 + state.invalidationsDrained += max(0, invalidationCount) + return token(.batchDrain, epoch: state.epoch) + } + } + + func fullGraphRebuildStarted() -> MobileWorkspaceObserverMetricToken? { + guard enabled.loadRelaxed() else { return nil } + return state.withLock { state -> MobileWorkspaceObserverMetricToken? in + guard state.enabled else { return nil } + state.fullGraphRebuilds += 1 + return token(.fullGraphRebuild, epoch: state.epoch) + } + } + + func incrementalRefreshStarted() -> MobileWorkspaceObserverMetricToken? { + guard enabled.loadRelaxed() else { return nil } + return state.withLock { state -> MobileWorkspaceObserverMetricToken? in + guard state.enabled else { return nil } + return token(.incrementalRefresh, epoch: state.epoch) + } + } + + func previewSignaturesStarted() -> MobileWorkspaceObserverMetricToken? { + guard enabled.loadRelaxed() else { return nil } + return state.withLock { state -> MobileWorkspaceObserverMetricToken? in + guard state.enabled else { return nil } + return token(.previewSignatures, epoch: state.epoch) + } + } + + func summaryHashStarted() -> MobileWorkspaceObserverMetricToken? { + guard enabled.loadRelaxed() else { return nil } + return state.withLock { state -> MobileWorkspaceObserverMetricToken? in + guard state.enabled else { return nil } + return token(.summaryHash, epoch: state.epoch) + } + } + + func operationCompleted( + _ token: MobileWorkspaceObserverMetricToken?, + workspacesRehashed: Int = 0 + ) { + guard enabled.loadRelaxed(), let token else { return } + let duration = elapsedMilliseconds(since: token.startedAtNanoseconds) + state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + state.workspacesRehashed += max(0, workspacesRehashed) + switch token.operation { + case .batchDrain: + state.batchDrainDuration.record(duration) + case .fullGraphRebuild: + state.fullGraphRebuildDuration.record(duration) + case .incrementalRefresh: + state.incrementalRefreshDuration.record(duration) + case .previewSignatures: + state.previewSignaturesDuration.record(duration) + case .summaryHash: + state.summaryHashDuration.record(duration) + } + } + } + + func recordEmit() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.emits += 1 + } + } + + func recordSkip() { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.skips += 1 + } + } + + private func token( + _ operation: MobileWorkspaceObserverMetricToken.Operation, + epoch: UInt64 + ) -> MobileWorkspaceObserverMetricToken { + MobileWorkspaceObserverMetricToken( + operation: operation, + epoch: epoch, + startedAtNanoseconds: monotonicNanoseconds() + ) + } + + private func elapsedMilliseconds(since start: UInt64) -> Double { + let end = monotonicNanoseconds() + return Double(end >= start ? end - start : 0) / 1_000_000 + } + + private static func unixMilliseconds() -> UInt64 { + UInt64(max(0, Date().timeIntervalSince1970 * 1_000)) + } +} + +private nonisolated extension MobileWorkspaceObserverDurationMetrics { + mutating func record(_ milliseconds: Double) { + count += 1 + totalMilliseconds += milliseconds + maximumMilliseconds = max(maximumMilliseconds, milliseconds) + lastMilliseconds = milliseconds + } +} diff --git a/Sources/PaneMemoryGuardrail.swift b/Sources/PaneMemoryGuardrail.swift index 33573cec40a5..029eac2a10bd 100644 --- a/Sources/PaneMemoryGuardrail.swift +++ b/Sources/PaneMemoryGuardrail.swift @@ -1,39 +1,29 @@ -import CmuxSettings import Foundation import Observation // MARK: - Monitor -/// One instance owns the background poll timer, scans every live pane each tick, -/// and attributes process-tree memory by controlling tty. The user-facing -/// warning badge and dismissible banner were removed in issue #6614, so the scan -/// now only maintains the engine's monitoring state (surfaced in DEBUG logs). -/// The heavy libproc scan runs off the main thread; only the small state updates -/// touch `@MainActor`. +/// DEBUG-only, one-shot pane process-tree memory attribution. The former +/// production timer and its user-facing warning UI were retired; callers must +/// explicitly request each diagnostic sample. @MainActor @Observable final class PaneMemoryGuardrail { static let shared = PaneMemoryGuardrail() - private static let enabledSetting = SettingCatalog().terminal.runawayMemoryGuardrailEnabled - private static let thresholdGBSetting = SettingCatalog().terminal.runawayMemoryGuardrailThresholdGB - private static let pollInterval: TimeInterval = 4 private static let scopedScanInterval: TimeInterval = 15 private static let defaultThresholdGB: Double = 8 private static let thresholdRangeGB: ClosedRange = 1...256 private static let bytesPerGB = 1024.0 * 1024.0 * 1024.0 - /// Supplies the live pane set each tick (main-actor; reads ghostty/tty). + /// Supplies the live pane set for an explicit sample (main-actor; reads + /// ghostty/tty). @ObservationIgnored var paneProvider: (@MainActor () -> [PaneMemoryDescriptor])? @ObservationIgnored private var engine = PaneMemoryGuardrailEngine() @ObservationIgnored - private let timerQueue = DispatchQueue(label: "com.cmux.pane-memory-guardrail", qos: .utility) - @ObservationIgnored - private var timer: DispatchSourceTimer? - @ObservationIgnored private var isScanning = false @ObservationIgnored private var scanApplyTask: Task? @@ -42,58 +32,34 @@ final class PaneMemoryGuardrail { @ObservationIgnored private var lastScopedScanAt = Date.distantPast - func start() { - guard timer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: timerQueue) - timer.schedule( - deadline: .now() + Self.pollInterval, - repeating: Self.pollInterval, - leeway: .seconds(1) - ) - timer.setEventHandler { [weak self] in - Task { @MainActor in self?.tick() } - } - self.timer = timer - timer.resume() - } - - // MARK: Settings - - private var isEnabled: Bool { - Self.enabledSetting.value(in: .standard) - } - - private func thresholdBytes() -> Int64 { - let raw = Self.thresholdGBSetting.value(in: .standard) - let gb = raw.isFinite ? min(max(raw, Self.thresholdRangeGB.lowerBound), Self.thresholdRangeGB.upperBound) : Self.defaultThresholdGB - return Int64(gb * Self.bytesPerGB) + /// Runs one sample on demand. This method is absent from release builds so + /// pane enumeration cannot become passive production work. +#if DEBUG + func runDiagnosticOnce(thresholdGB raw: Double = 8) { + let gb = raw.isFinite + ? min(max(raw, Self.thresholdRangeGB.lowerBound), Self.thresholdRangeGB.upperBound) + : Self.defaultThresholdGB + sampleOnce(thresholdBytes: Int64(gb * Self.bytesPerGB)) } +#endif - // MARK: Tick + // MARK: One-shot sampling - private func tick() { - guard isEnabled else { - clearAll() - return - } + private func sampleOnce(thresholdBytes: Int64) { guard !isScanning, let paneProvider else { return } let descriptors = paneProvider() guard !descriptors.isEmpty else { clearAll() return } - let thresholdBytes = thresholdBytes() let includeCMUXScope = consumeScopedScanIfDue(now: Date()) isScanning = true - let sampleTask = Task.detached(priority: .utility) { - Self.computeCachedSamples( + scanApplyTask = Task { @MainActor [weak self] in + let batch = await Self.computeCachedSamples( descriptors: descriptors, thresholdBytes: thresholdBytes, includeCMUXScope: includeCMUXScope ) - } - scanApplyTask = Task { @MainActor [weak self] in - let batch = await sampleTask.value guard !Task.isCancelled else { return } self?.applySamples( batch, @@ -106,16 +72,15 @@ final class PaneMemoryGuardrail { descriptors: [PaneMemoryDescriptor], thresholdBytes: Int64, includeCMUXScope: Bool = false - ) -> PaneMemoryGuardrailSampleBatch { - // The unscoped maximumAge must stay below pollInterval (4s): serving the - // guardrail its own previous tick's snapshot would silently halve its - // effective sampling cadence. 3s only allows reuse of a snapshot another - // subsystem (autosave, task manager) captured moments earlier; when the - // guardrail is the sole sampler it still captures fresh each tick, which - // is the cheap no-details tier and the intended freshness floor. - let snapshot = includeCMUXScope - ? CmuxTopProcessSnapshot.captureCached(includeCMUXScope: true, maximumAge: 5) - : CmuxTopProcessSnapshot.captureCached(includeCMUXScope: false, maximumAge: 3) + ) async -> PaneMemoryGuardrailSampleBatch { + // Reuse a process snapshot another subsystem captured moments earlier; + // an explicit diagnostic otherwise performs one fresh cheap scan. + let requirements: CmuxTopProcessSnapshotRequirements = includeCMUXScope ? .cmuxScope : .basic + let snapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: requirements, + maximumAge: includeCMUXScope ? 5 : 3, + consumer: .memoryGuardrail + ) let samples = computeSamples( descriptors: descriptors, thresholdBytes: thresholdBytes, diff --git a/Sources/Panels/BrowserPanel+AddressBarFocusOwnership.swift b/Sources/Panels/BrowserPanel+AddressBarFocusOwnership.swift new file mode 100644 index 000000000000..43816b267f0d --- /dev/null +++ b/Sources/Panels/BrowserPanel+AddressBarFocusOwnership.swift @@ -0,0 +1,118 @@ +import Foundation + +extension BrowserPanel { + func beginSuppressWebViewFocusForAddressBar() { + let enteringAddressBar = !hasAddressBarFocusSuppression + if enteringAddressBar { +#if DEBUG + cmuxDebugLog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") +#endif + invalidateAddressBarPageFocusRestoreAttempts() + } + suppressWebViewFocusForAddressBarIntent = true + if enteringAddressBar { + captureAddressBarPageFocusIfNeeded() + } + } + + func endSuppressWebViewFocusForAddressBar() { + if hasAddressBarFocusSuppression { +#if DEBUG + cmuxDebugLog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") +#endif + } + suppressWebViewFocusForAddressBarIntent = false + addressBarViewFocusLeaseOwners.removeAll() + } + + func endAddressBarFocusIntentSuppression() { + suppressWebViewFocusForAddressBarIntent = false + } + + /// End an explicit address-bar-to-WebKit handoff as one panel-wide state + /// transition. Overlapping SwiftUI view lifetimes can temporarily hold more + /// than one owner lease, so a handoff must revoke every lease and notify every + /// mounted view instead of releasing only the initiating view's owner. + func endAddressBarFocusForWebViewHandoff(reason: String) { + pendingAddressBarFocusRequestId = nil + pendingAddressBarFocusSelectionIntent = .preserveFieldEditorSelection + endSuppressWebViewFocusForAddressBar() + invalidateAddressBarPageFocusRestoreAttempts() + AppDelegate.shared?.clearBrowserAddressBarFocus(panelId: id, reason: reason) + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) + } + + /// Register one view lifetime as the newest address-bar presentation. A + /// repeated SwiftUI `onAppear` for the same lifetime is intentionally a no-op, + /// so an outgoing view cannot reclaim ownership after its replacement mounts. + @discardableResult + func registerAddressBarViewPresentation(owner: UUID) -> Bool { + guard !addressBarViewPresentationOwners.contains(owner) else { return false } + addressBarViewPresentationOwners.append(owner) + currentAddressBarViewPresentationOwner = owner + return true + } + + /// Remove only the disappearing view lifetime. Unregistering a stale owner + /// cannot clear its replacement; removing the newest owner promotes the most + /// recent surviving presentation. + @discardableResult + func unregisterAddressBarViewPresentation(owner: UUID) -> Bool { + guard let index = addressBarViewPresentationOwners.firstIndex(of: owner) else { return false } + let wasCurrent = currentAddressBarViewPresentationOwner == owner + addressBarViewPresentationOwners.remove(at: index) + if wasCurrent { + currentAddressBarViewPresentationOwner = addressBarViewPresentationOwners.last + } + return true + } + + /// Transfer active address-bar focus from a view-local SwiftUI lifetime into + /// model-owned suppression. Owner identity makes overlapping unmount/remount + /// transitions safe: an old view can release only its own lease. + @discardableResult + func acquireAddressBarViewFocusLease(owner: UUID, reason: String) -> Bool { + let enteringAddressBar = !hasAddressBarFocusSuppression + guard addressBarViewFocusLeaseOwners.insert(owner).inserted else { return false } + if enteringAddressBar { + invalidateAddressBarPageFocusRestoreAttempts() + captureAddressBarPageFocusIfNeeded() + } +#if DEBUG + cmuxDebugLog( + "browser.focus.addressBarLease.acquire panel=\(id.uuidString.prefix(5)) " + + "owner=\(owner.uuidString.prefix(8)) count=\(addressBarViewFocusLeaseOwners.count) " + + "reason=\(reason)" + ) +#endif + if addressBarViewFocusLeaseOwners.count == 1 { + NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: id) + } + return true + } + + /// Release one mounted view's address-bar focus lease. Global responder + /// tracking is cleared only after the final owner leaves, so a stale view + /// disappearing after its replacement cannot blur the replacement. + @discardableResult + func relinquishAddressBarViewFocusLease(owner: UUID, reason: String) -> Bool { + guard addressBarViewFocusLeaseOwners.remove(owner) != nil else { return false } +#if DEBUG + cmuxDebugLog( + "browser.focus.addressBarLease.release panel=\(id.uuidString.prefix(5)) " + + "owner=\(owner.uuidString.prefix(8)) count=\(addressBarViewFocusLeaseOwners.count) " + + "reason=\(reason)" + ) +#endif + if addressBarViewFocusLeaseOwners.isEmpty { + invalidateAddressBarPageFocusRestoreAttempts() + AppDelegate.shared?.clearBrowserAddressBarFocus(panelId: id, reason: reason) + NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) + } + return true + } + + var hasAddressBarFocusSuppression: Bool { + suppressWebViewFocusForAddressBarIntent || !addressBarViewFocusLeaseOwners.isEmpty + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6140db6976b1..064b49859710 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2760,7 +2760,12 @@ final class BrowserPanel: Panel, ObservableObject { /// Prevent forcing web-view focus when another UI path requested omnibar focus. /// Used to keep omnibar text-field focus from being immediately stolen by panel focus. private var suppressWebViewFocusUntil: Date? - private var suppressWebViewFocusForAddressBar: Bool = false + /// Model-owned suppression requested before an address-bar view has accepted + /// focus. Mounted views hold separate owner-scoped leases so a disappearing + /// stale view cannot clear a replacement view's suppression, and an + /// acknowledged request cannot leave suppression latched without an owner. + var suppressWebViewFocusForAddressBarIntent: Bool = false + var addressBarViewFocusLeaseOwners: Set = [] private let blankURLString = "about:blank" /// Owns the address-bar page-focus capture/restore subsystem. @@ -2909,8 +2914,15 @@ final class BrowserPanel: Panel, ObservableObject { /// Sticky omnibar-focus intent. This survives view mount timing races and is /// cleared only after BrowserPanelView acknowledges handling it. - @Published private(set) var pendingAddressBarFocusRequestId: UUID? - private(set) var pendingAddressBarFocusSelectionIntent: BrowserAddressBarFocusSelectionIntent = .preserveFieldEditorSelection + @Published var pendingAddressBarFocusRequestId: UUID? + var pendingAddressBarFocusSelectionIntent: BrowserAddressBarFocusSelectionIntent = .preserveFieldEditorSelection + + /// The newest mounted SwiftUI presentation for this model. Ordinary workspace + /// recreation can overlap outgoing and replacement views for the same panel + /// and pane, so pane identity alone cannot decide which view may consume a + /// durable focus request. + @Published var currentAddressBarViewPresentationOwner: UUID? + var addressBarViewPresentationOwners: [UUID] = [] /// Per-surface browser chrome visibility. Diff and artifact viewers can hide /// the omnibar without changing the global browser default. @@ -7160,6 +7172,7 @@ extension BrowserPanel { let shouldSelectAll = created && !recoveredNeedle.isEmpty pendingAddressBarFocusRequestId = nil pendingAddressBarFocusSelectionIntent = .preserveFieldEditorSelection + endSuppressWebViewFocusForAddressBar() NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: id) let generation = beginSearchFocusRequest(reason: "startFind") postBrowserSearchFocusNotification(reason: "immediate", generation: generation, selectAll: shouldSelectAll) @@ -7459,7 +7472,7 @@ extension BrowserPanel { } func shouldSuppressWebViewFocus() -> Bool { - if suppressWebViewFocusForAddressBar { + if hasAddressBarFocusSuppression { return true } if searchState != nil { @@ -7471,28 +7484,6 @@ extension BrowserPanel { return false } - func beginSuppressWebViewFocusForAddressBar() { - let enteringAddressBar = !suppressWebViewFocusForAddressBar - if enteringAddressBar { -#if DEBUG - cmuxDebugLog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") -#endif - invalidateAddressBarPageFocusRestoreAttempts() - } - suppressWebViewFocusForAddressBar = true - if enteringAddressBar { - captureAddressBarPageFocusIfNeeded() - } - } - - func endSuppressWebViewFocusForAddressBar() { - if suppressWebViewFocusForAddressBar { -#if DEBUG - cmuxDebugLog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") -#endif - } - suppressWebViewFocusForAddressBar = false - } @discardableResult func requestAddressBarFocus( @@ -7756,6 +7747,7 @@ extension BrowserPanel { } pendingAddressBarFocusRequestId = nil pendingAddressBarFocusSelectionIntent = .preserveFieldEditorSelection + endAddressBarFocusIntentSuppression() #if DEBUG cmuxDebugLog( "browser.focus.addressBar.requestAck panel=\(id.uuidString.prefix(5)) " + @@ -7764,7 +7756,7 @@ extension BrowserPanel { #endif } - private func captureAddressBarPageFocusIfNeeded() { + func captureAddressBarPageFocusIfNeeded() { omnibarPageFocusRepository.captureIfNeeded(panelDebugID: String(id.uuidString.prefix(5))) } diff --git a/Sources/Panels/BrowserPanelView+AddressBarFocusRequest.swift b/Sources/Panels/BrowserPanelView+AddressBarFocusRequest.swift new file mode 100644 index 000000000000..867cc1b22d0a --- /dev/null +++ b/Sources/Panels/BrowserPanelView+AddressBarFocusRequest.swift @@ -0,0 +1,109 @@ +import Foundation +import SwiftUI +import AppKit + +extension BrowserPanelView { + func applyPendingAddressBarFocusRequestIfNeeded() { + guard let requestId = panel.pendingAddressBarFocusRequestId else { + return + } + // A recreated workspace can briefly leave outgoing and replacement views + // subscribed to the same model. Only the visible pane owner may consume + // the durable request; otherwise the outgoing view can acknowledge Cmd+L + // immediately before disappearing and strand the replacement unfocused. + guard isVisibleInUI, + isCurrentPaneOwner, + panel.currentAddressBarViewPresentationOwner == addressBarFocusLeaseOwner else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=inactive_view request=\(requestId.uuidString.prefix(8))" + ) +#endif + return + } + guard panel.panelType == .browser else { + lastHandledAddressBarFocusRequestId = requestId + panel.acknowledgeAddressBarFocusRequest(requestId) +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=chrome_hidden request=\(requestId.uuidString.prefix(8))" + ) +#endif + return + } + guard !isCommandPaletteVisibleForPanelWindow() else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))" + ) +#endif + return + } + guard lastHandledAddressBarFocusRequestId != requestId else { +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply.skip", + detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))" + ) +#endif + return + } + lastHandledAddressBarFocusRequestId = requestId + let selectionIntent = panel.pendingAddressBarFocusSelectionIntent + panel.beginSuppressWebViewFocusForAddressBar() + panel.acquireAddressBarViewFocusLease( + owner: addressBarFocusLeaseOwner, + reason: "addressBarFocus.request.apply" + ) +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) selection=\(String(describing: selectionIntent))" + ) +#endif + + if addressBarFocused { + // Re-run explicit selection behavior only for requests that own it + // (Cmd+L), without replacing a caret from focus restoration. + let effects = omnibarReduce( + state: &omnibarState, + event: .focusReasserted( + shouldSelectAll: browserOmnibarShouldSelectAllOnFocusReassertion( + selectionIntent: selectionIntent + ) + ) + ) + applyOmnibarEffects(effects) + refreshInlineCompletion() +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh" + ) +#endif + } else { + setAddressBarFocused( + true, + reason: "request.apply", + focusGainedSelectionIntent: selectionIntent + ) +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.apply", + detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused" + ) +#endif + } + + panel.acknowledgeAddressBarFocusRequest(requestId) +#if DEBUG + logBrowserFocusState( + event: "addressBarFocus.request.ack", + detail: "request=\(requestId.uuidString.prefix(8))" + ) +#endif + } +} diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 75c22d733d63..bd40abb06ae8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -262,8 +262,12 @@ struct BrowserPanelView: View { /// through this rather than the former `BrowserInstalledBrowserDetector` static /// namespace. private let installedBrowserDetector = BrowserInstalledBrowserDetector() - @State private var omnibarState = OmnibarState() - @State private var addressBarFocused: Bool = false + // Omnibar editing stays view-local: focus loss reverts an uncommitted draft, while an + // ordinary-workspace unmount discards that view-local state. BrowserPanel retains the + // committed URL, navigation state, WKWebView, and owner-scoped focus suppression. + @State var omnibarState = OmnibarState() + @State var addressBarFocused: Bool = false + @State var addressBarFocusLeaseOwner = UUID() @AppStorage(BrowserSearchSettingsStore.searchEngineKey) private var searchEngineRaw = BrowserSearchSettingsStore.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettingsStore.customSearchEngineNameKey) private var customSearchEngineName = BrowserSearchSettingsStore.defaultCustomSearchEngineName @AppStorage(BrowserSearchSettingsStore.customSearchEngineURLTemplateKey) private var customSearchEngineURLTemplate = BrowserSearchSettingsStore.defaultCustomSearchEngineURLTemplate @@ -312,7 +316,7 @@ struct BrowserPanelView: View { } @State private var isBrowserImportHintPopoverPresented = false @State private var focusModeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) - @State private var lastHandledAddressBarFocusRequestId: UUID? + @State var lastHandledAddressBarFocusRequestId: UUID? @State private var omnibarSelectAllRequestId: UInt64 = 0 @State private var pendingFocusGainedSelectionIntent: BrowserAddressBarFocusSelectionIntent = .preserveFieldEditorSelection @State private var isBrowserProfileMenuPresented = false @@ -521,7 +525,7 @@ struct BrowserPanelView: View { return manager.tabs.first(where: { $0.id == panel.workspaceId }) } - private var isCurrentPaneOwner: Bool { + var isCurrentPaneOwner: Bool { // Dock (and other non-Workspace hosts) inject ownership explicitly, since // their panels are not registered in the main Workspace tree. if let paneOwnershipOverride { @@ -680,6 +684,7 @@ struct BrowserPanelView: View { // already handled by the dedicated `.onChange` observers on `body`, so // re-running this per appear is harmless once the heavy/one-time work is // gated out. + panel.registerAddressBarViewPresentation(owner: addressBarFocusLeaseOwner) performInitialBrowserPanelSetupIfNeeded() startOmnibarSuggestionRefreshConsumer() refreshBrowserChromeStyle() @@ -693,6 +698,12 @@ struct BrowserPanelView: View { syncURLFromPanel() // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() + if addressBarFocused { + panel.acquireAddressBarViewFocusLease( + owner: addressBarFocusLeaseOwner, + reason: "view.onAppear" + ) + } syncWebViewResponderPolicyWithViewState(reason: "onAppear") panel.historyStore.loadIfNeeded() #if DEBUG @@ -730,6 +741,11 @@ struct BrowserPanelView: View { } private func handleBrowserPanelDisappear() { + panel.relinquishAddressBarViewFocusLease( + owner: addressBarFocusLeaseOwner, + reason: "view.onDisappear" + ) + panel.unregisterAddressBarViewPresentation(owner: addressBarFocusLeaseOwner) stopOmnibarSuggestionRefreshConsumer() cancelPendingOmnibarSuggestionWork() focusModeShortcutHintMonitor.stop() @@ -754,7 +770,7 @@ struct BrowserPanelView: View { #if DEBUG logBrowserFocusState(event: "addressBarFocus.webViewClickBlur") #endif - setAddressBarFocused(false, reason: "webView.clickIntent") + endAddressBarFocusForPanelWideExit(reason: "webView.clickIntent") } if !isFocused { onRequestPanelFocus() @@ -834,6 +850,7 @@ struct BrowserPanelView: View { ) if visibleInUI { panel.cancelPendingDeveloperToolsVisibilityLossCheck() + applyPendingAddressBarFocusRequestIfNeeded() return } // Pane/workspace churn can briefly mark the browser hidden before the @@ -849,7 +866,8 @@ struct BrowserPanelView: View { detail: "next=\(focused ? 1 : 0)" ) #endif - // Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive). + // Ensure this view doesn't retain focus after its panel stops being selected. Ordinary + // workspaces unmount it; hosts such as the Dock can keep the hidden view alive. if focused { applyPendingAddressBarFocusRequestIfNeeded() autoFocusOmnibarIfBlank() @@ -883,8 +901,10 @@ struct BrowserPanelView: View { if focused { let selectionIntent = pendingFocusGainedSelectionIntent pendingFocusGainedSelectionIntent = .preserveFieldEditorSelection - panel.beginSuppressWebViewFocusForAddressBar() - NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id) + panel.acquireAddressBarViewFocusLease( + owner: addressBarFocusLeaseOwner, + reason: "addressBarFocusChanged.focused" + ) // Only request panel focus if this pane isn't currently focused. When already // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit. if !isFocused { @@ -901,8 +921,10 @@ struct BrowserPanelView: View { refreshInlineCompletion() } else { pendingFocusGainedSelectionIntent = .preserveFieldEditorSelection - panel.endSuppressWebViewFocusForAddressBar() - NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panel.id) + panel.relinquishAddressBarViewFocusLease( + owner: addressBarFocusLeaseOwner, + reason: "addressBarFocusChanged.blurred" + ) if suppressNextFocusLostRevert { suppressNextFocusLostRevert = false let effects = omnibarReduce(state: &omnibarState, event: .focusLostPreserveBuffer(currentURLString: urlString)) @@ -1055,6 +1077,9 @@ struct BrowserPanelView: View { .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } + .onChange(of: panel.currentAddressBarViewPresentationOwner) { _, _ in + applyPendingAddressBarFocusRequestIfNeeded() + } .onChange(of: panel.isOmnibarVisible) { _, isVisible in handleOmnibarVisibilityChange(isVisible) } @@ -1710,7 +1735,7 @@ struct BrowserPanelView: View { #if DEBUG logBrowserFocusState(event: "webContent.tapBlur") #endif - setAddressBarFocused(false, reason: "webContent.tapBlur") + endAddressBarFocusForPanelWideExit(reason: "webContent.tapBlur") } }) } else { @@ -1720,7 +1745,7 @@ struct BrowserPanelView: View { .onTapGesture { onRequestPanelFocus() if addressBarFocused { - setAddressBarFocused(false, reason: "placeholderContent.tapBlur") + endAddressBarFocusForPanelWideExit(reason: "placeholderContent.tapBlur") } } .overlay(alignment: .topLeading) { @@ -1840,7 +1865,17 @@ struct BrowserPanelView: View { canHandleOmnibarSelectionNavigation() && hasActionableOmnibarSuggestions } - private func setAddressBarFocused( + /// Navigation, suggestion commits, content clicks, and Escape all end the + /// address-bar interaction for the panel, not just for one overlapping view + /// lifetime. The model broadcasts the blur so every mounted presentation + /// drops its local focus state before WebKit responder policy is reevaluated. + private func endAddressBarFocusForPanelWideExit(reason: String) { + panel.endAddressBarFocusForWebViewHandoff(reason: reason) + syncWebViewResponderPolicyWithViewState(reason: "\(reason).preHandoff") + setAddressBarFocused(false, reason: reason) + } + + func setAddressBarFocused( _ focused: Bool, reason: String, focusGainedSelectionIntent: BrowserAddressBarFocusSelectionIntent = .preserveFieldEditorSelection @@ -1915,7 +1950,7 @@ struct BrowserPanelView: View { return String(describing: type(of: responder)) } - private func logBrowserFocusState(event: String, detail: String = "") { + func logBrowserFocusState(event: String, detail: String = "") { let window = browserFocusWindow() let firstResponder = window?.firstResponder let firstResponderType = browserFocusResponderDescription(firstResponder) @@ -1942,7 +1977,7 @@ struct BrowserPanelView: View { applyOmnibarEffects(effects) } - private func isCommandPaletteVisibleForPanelWindow() -> Bool { + func isCommandPaletteVisibleForPanelWindow() -> Bool { guard let app = AppDelegate.shared else { return false } if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) { @@ -1988,90 +2023,6 @@ struct BrowserPanelView: View { return false } - private func applyPendingAddressBarFocusRequestIfNeeded() { - guard let requestId = panel.pendingAddressBarFocusRequestId else { - return - } - guard panel.panelType == .browser else { - lastHandledAddressBarFocusRequestId = requestId - panel.acknowledgeAddressBarFocusRequest(requestId) -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.apply.skip", - detail: "reason=chrome_hidden request=\(requestId.uuidString.prefix(8))" - ) -#endif - return - } - guard !isCommandPaletteVisibleForPanelWindow() else { -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.apply.skip", - detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))" - ) -#endif - return - } - guard lastHandledAddressBarFocusRequestId != requestId else { -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.apply.skip", - detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))" - ) -#endif - return - } - lastHandledAddressBarFocusRequestId = requestId - let selectionIntent = panel.pendingAddressBarFocusSelectionIntent - panel.beginSuppressWebViewFocusForAddressBar() -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.apply", - detail: "request=\(requestId.uuidString.prefix(8)) selection=\(String(describing: selectionIntent))" - ) -#endif - - if addressBarFocused { - // Re-run explicit selection behavior only for requests that own it - // (Cmd+L), without replacing a caret from focus restoration. - let effects = omnibarReduce( - state: &omnibarState, - event: .focusReasserted( - shouldSelectAll: browserOmnibarShouldSelectAllOnFocusReassertion( - selectionIntent: selectionIntent - ) - ) - ) - applyOmnibarEffects(effects) - refreshInlineCompletion() -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.apply", - detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh" - ) -#endif - } else { - setAddressBarFocused( - true, - reason: "request.apply", - focusGainedSelectionIntent: selectionIntent - ) -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.apply", - detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused" - ) -#endif - } - - panel.acknowledgeAddressBarFocusRequest(requestId) -#if DEBUG - logBrowserFocusState( - event: "addressBarFocus.request.ack", - detail: "request=\(requestId.uuidString.prefix(8))" - ) -#endif - } private var emptyBrowserStateCardOverlay: some View { VStack { @@ -2384,10 +2335,10 @@ struct BrowserPanelView: View { let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(text)) applyOmnibarEffects(effects) } - panel.navigateSmart(text) hideSuggestions() suppressNextFocusLostRevert = true - setAddressBarFocused(false, reason: "omnibar.submit.navigate") + endAddressBarFocusForPanelWideExit(reason: "omnibar.submit.navigate") + panel.navigateSmart(text) } } @@ -2401,16 +2352,16 @@ struct BrowserPanelView: View { // Treat this as a commit, not a user edit: don't refetch suggestions while we're navigating away. omnibarState.buffer = suggestion.completion omnibarState.isUserEditing = false + hideSuggestions() + inlineCompletion = nil + suppressNextFocusLostRevert = true + endAddressBarFocusForPanelWideExit(reason: "suggestion.commit") switch suggestion.kind { case .switchToTab(let tabId, let panelId, _, _): AppDelegate.shared?.tabManager?.focusTab(tabId, surfaceId: panelId) default: panel.navigateSmart(suggestion.completion) } - hideSuggestions() - inlineCompletion = nil - suppressNextFocusLostRevert = true - setAddressBarFocused(false, reason: "suggestion.commit") } private func handleOmnibarEscape() { @@ -2553,7 +2504,7 @@ struct BrowserPanelView: View { _ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue) } - private func refreshInlineCompletion() { + func refreshInlineCompletion() { inlineCompletion = omnibarInlineCompletionForDisplay( typedText: omnibarState.buffer, suggestions: omnibarState.suggestions, @@ -2758,7 +2709,7 @@ struct BrowserPanelView: View { return values.isEmpty ? nil : values } - private func applyOmnibarEffects(_ effects: OmnibarEffects) { + func applyOmnibarEffects(_ effects: OmnibarEffects) { if effects.shouldCancelPendingSuggestionRefresh { cancelPendingOmnibarSuggestionWork() } @@ -2773,11 +2724,7 @@ struct BrowserPanelView: View { } if effects.shouldBlurToWebView { hideSuggestions() - // This transition is stateful: drop omnibar focus suppression before - // attempting responder handoff so WKWebView can actually become first responder. - panel.endSuppressWebViewFocusForAddressBar() - syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff") - setAddressBarFocused(false, reason: "effects.blurToWebView") + endAddressBarFocusForPanelWideExit(reason: "effects.blurToWebView") DispatchQueue.main.async { guard let window = panel.webView.window, !panel.webView.isHiddenOrHasHiddenAncestor else { return } diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 2282189e0746..0324fb88a4d7 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -990,9 +990,8 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan @Published private(set) var isSaving = false @Published private(set) var focusFlashToken = 0 @Published private(set) var previewMode: FilePreviewMode - let nativeViewSessions = FilePreviewNativeViewSessions() - + let textEditorSession = FilePreviewTextEditorSession() private var originalTextContent = "" private var textEncoding: String.Encoding = .utf8 private var previewModeGeneration = 0 @@ -1041,10 +1040,10 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan func close() { nativeViewSessions.closeAll() + textEditorSession.close() textView = nil focusCoordinator.unregisterAll() } - func triggerFlash(reason: WorkspaceAttentionFlashReason) { _ = reason guard NotificationPaneFlashSettings.isEnabled() else { return } @@ -1355,6 +1354,7 @@ struct FilePreviewPanelView: View { case .text: FilePreviewTextEditor( panel: panel, + session: panel.textEditorSession, isVisibleInUI: isVisibleInUI, themeBackgroundColor: contentBackgroundColor, themeForegroundColor: themeForegroundColor, diff --git a/Sources/Panels/FilePreviewTextEditor.swift b/Sources/Panels/FilePreviewTextEditor.swift index f24f6ffa0c01..f35bafdb58f4 100644 --- a/Sources/Panels/FilePreviewTextEditor.swift +++ b/Sources/Panels/FilePreviewTextEditor.swift @@ -16,6 +16,7 @@ protocol FilePreviewTextEditingPanel: AnyObject { struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: ObservableObject & FilePreviewTextEditingPanel { @ObservedObject var panel: PanelModel + let session: FilePreviewTextEditorSession let isVisibleInUI: Bool let themeBackgroundColor: NSColor let themeForegroundColor: NSColor @@ -29,7 +30,10 @@ struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: } func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() + let views = session.editorViews() + let scrollView = views.scrollView + let textView = views.textView + scrollView.removeFromSuperview() scrollView.isHidden = !isVisibleInUI scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true @@ -37,14 +41,16 @@ struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: scrollView.borderType = .noBorder scrollView.drawsBackground = drawsBackground - let textView = SavingTextView.makeFilePreviewTextView() textView.panel = panel textView.delegate = context.coordinator textView.drawsBackground = drawsBackground - textView.string = panel.textContent + if textView.string != panel.textContent { + context.coordinator.isApplyingPanelUpdate = true + textView.string = panel.textContent + context.coordinator.isApplyingPanelUpdate = false + } panel.attachTextView(textView) - scrollView.documentView = textView textView.applyFilePreviewWordWrap(wordWrap, scrollView: scrollView) Self.applyTheme( to: scrollView, @@ -55,6 +61,13 @@ struct FilePreviewTextEditor: NSViewRepresentable where PanelModel: return scrollView } + static func dismantleNSView(_ scrollView: NSScrollView, coordinator: Coordinator) { + guard let textView = scrollView.documentView as? SavingTextView, + textView.delegate === coordinator else { return } + textView.delegate = nil + textView.panel = nil + } + func updateNSView(_ scrollView: NSScrollView, context: Context) { context.coordinator.panel = panel scrollView.isHidden = !isVisibleInUI diff --git a/Sources/Panels/FilePreviewTextEditorSession.swift b/Sources/Panels/FilePreviewTextEditorSession.swift new file mode 100644 index 000000000000..4cc0afd25255 --- /dev/null +++ b/Sources/Panels/FilePreviewTextEditorSession.swift @@ -0,0 +1,29 @@ +import AppKit + +/// Owns the native file-editor view across SwiftUI tab-host remounts. +@MainActor +final class FilePreviewTextEditorSession { + private var retainedScrollView: NSScrollView? + private var retainedTextView: SavingTextView? + + func editorViews() -> (scrollView: NSScrollView, textView: SavingTextView) { + if let retainedScrollView, let retainedTextView { + return (retainedScrollView, retainedTextView) + } + + let scrollView = NSScrollView() + let textView = SavingTextView.makeFilePreviewTextView() + scrollView.documentView = textView + retainedScrollView = scrollView + retainedTextView = textView + return (scrollView, textView) + } + + func close() { + retainedTextView?.delegate = nil + retainedTextView?.panel = nil + retainedScrollView?.removeFromSuperview() + retainedScrollView = nil + retainedTextView = nil + } +} diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 09ec1a2daf14..fd2d7da023e2 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -72,6 +72,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// Stable markdown renderer state. Keep this panel-owned so split/tab /// layout churn does not recreate the WKWebView and flash existing content. let rendererSession = MarkdownRendererSession() + let textEditorSession = FilePreviewTextEditorSession() // MARK: - File watching @@ -245,6 +246,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel func close() { isClosed = true rendererSession.close() + textEditorSession.close() GlobalSearchCoordinator.shared.purgePanel(id: id) textView = nil stopWatching() diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index 54458b161888..327135078a5e 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -100,6 +100,7 @@ struct MarkdownPanelView: View { if panel.displayMode == .text { FilePreviewTextEditor( panel: panel, + session: panel.textEditorSession, isVisibleInUI: isVisibleInUI, themeBackgroundColor: appearance.contentBackgroundColor, themeForegroundColor: themeForegroundColor, diff --git a/Sources/PortScanSnapshotStore.swift b/Sources/PortScanSnapshotStore.swift new file mode 100644 index 000000000000..bb78eb91b0a8 --- /dev/null +++ b/Sources/PortScanSnapshotStore.swift @@ -0,0 +1,292 @@ +import Foundation + +/// Owns all local listener scans so PortScanner consumers share one bounded +/// libproc capture and a short-lived superset cache. +actor PortScanSnapshotStore { + typealias Now = @Sendable () async -> Date + typealias Capture = @Sendable (Set) async -> [Int: Set] + typealias ProvenancedCapture = @Sendable (Set) async -> ( + portsByPID: [Int: Set], + proof: ProcessPerformanceCaptureProof + ) + typealias EvidencedProvenancedCapture = @Sendable (Set) async -> ( + scan: PortLsofScanResult, + proof: ProcessPerformanceCaptureProof + ) + + private struct CachedScan { + let requestedPIDs: Set + let scan: PortLsofScanResult + let storedAt: Date + let metricsToken: ProcessPerformanceMetricToken + } + + private struct InFlightScan { + let id: UInt64 + let requestedPIDs: Set + let task: Task<( + scan: PortLsofScanResult, + proof: ProcessPerformanceCaptureProof + ), Never> + let metricsToken: ProcessPerformanceMetricToken + } + + private struct PerformanceExercise { + let gate: ProcessPerformanceExerciseGate + var captureID: UInt64? + var joined = false + var proof: ProcessPerformanceCaptureProof? + } + + private let now: Now + private let capture: EvidencedProvenancedCapture + private let metrics: ProcessPerformanceMetrics + private var cached: CachedScan? + private var inFlight: InFlightScan? + private var pendingPIDs: Set = [] + private var nextCaptureID: UInt64 = 0 + private var performanceExercise: PerformanceExercise? + + init( + now: @escaping Now = { Date() }, + capture: @escaping Capture, + metrics: ProcessPerformanceMetrics = .shared + ) { + self.now = now + self.capture = { pids in + ( + PortLsofScanResult( + values: await capture(pids), + globallyComplete: true, + incompletePIDs: [] + ), + .libproc + ) + } + self.metrics = metrics + } + + init( + now: @escaping Now = { Date() }, + captureWithProof: @escaping ProvenancedCapture, + metrics: ProcessPerformanceMetrics = .shared + ) { + self.now = now + self.capture = { pids in + let result = await captureWithProof(pids) + return ( + PortLsofScanResult( + values: result.portsByPID, + globallyComplete: true, + incompletePIDs: [] + ), + result.proof + ) + } + self.metrics = metrics + } + + init( + now: @escaping Now = { Date() }, + captureWithEvidenceAndProof: @escaping EvidencedProvenancedCapture, + metrics: ProcessPerformanceMetrics = .shared + ) { + self.now = now + self.capture = captureWithEvidenceAndProof + self.metrics = metrics + } + + func snapshot( + pids: Set, + maximumAge: TimeInterval + ) async -> [Int: Set] { + await evidencedSnapshot(pids: pids, maximumAge: maximumAge).values + } + + func evidencedSnapshot( + pids: Set, + maximumAge: TimeInterval + ) async -> PortLsofScanResult { + if let exercise = performanceExercise, + !ProcessPerformanceExerciseContext.isListenerExerciseRequest { + await exercise.gate.waitUntilFinished() + return await evidencedSnapshot(pids: pids, maximumAge: maximumAge) + } + let requestedPIDs = Set(pids.filter { $0 > 0 }) + guard !requestedPIDs.isEmpty else { + return PortLsofScanResult(values: [:], globallyComplete: true, incompletePIDs: []) + } + + while true { + let requestedAt = await now() + if let cached = validCachedScan( + covering: requestedPIDs, + maximumAge: maximumAge, + now: requestedAt + ) { + metrics.recordLsofReuse(.cache, token: cached.metricsToken) + return cached.scan + } + + if let active = inFlight { + if active.requestedPIDs.isSuperset(of: requestedPIDs) { + if ProcessPerformanceExerciseContext.isListenerExerciseRequest, + performanceExercise?.captureID == active.id { + performanceExercise?.joined = true + await performanceExercise?.gate.recordJoin() + } + metrics.recordLsofReuse(.inFlight, token: active.metricsToken) + let captureResult = await active.task.value + await finishCapture(active, captureResult: captureResult) + return captureResult.scan + } + + pendingPIDs.formUnion(requestedPIDs) + metrics.recordLsofCoalescedRequest(token: active.metricsToken) + let captureResult = await active.task.value + await finishCapture(active, captureResult: captureResult) + continue + } + + pendingPIDs.formUnion(requestedPIDs) + let capturePIDs = pendingPIDs + pendingPIDs.removeAll(keepingCapacity: true) + nextCaptureID &+= 1 + let id = nextCaptureID + let capture = self.capture + let exerciseGate = ProcessPerformanceExerciseContext.isListenerExerciseRequest + ? performanceExercise?.gate + : nil + if exerciseGate != nil { + performanceExercise?.captureID = id + await exerciseGate?.recordGeneration(id) + } + let metricsToken = metrics.lsofStarted(pidCount: capturePIDs.count) + let task = Task.detached(priority: .utility) { + let result = await capture(capturePIDs) + if let exerciseGate { + await exerciseGate.waitForCaptureRelease() + } + return result + } + let started = InFlightScan( + id: id, + requestedPIDs: capturePIDs, + task: task, + metricsToken: metricsToken + ) + inFlight = started + let captureResult = await task.value + await finishCapture(started, captureResult: captureResult) + if capturePIDs.isSuperset(of: requestedPIDs) { + return captureResult.scan + } + } + } + + /// Forces two real listener requests through the normal sharing branch. + func performanceMetricsExercise(pids: Set) async -> ( + proof: ProcessPerformanceCaptureProof, + sharedResult: Bool + )? { + while let active = inFlight { + let captureResult = await active.task.value + await finishCapture(active, captureResult: captureResult) + } + cached = nil + pendingPIDs.removeAll(keepingCapacity: true) + + let capturePIDs = Set(pids.filter { $0 > 0 }) + let gate = ProcessPerformanceExerciseGate() + performanceExercise = PerformanceExercise(gate: gate) + return await withTaskCancellationHandler { + await runPerformanceMetricsExercise(pids: capturePIDs, gate: gate) + } onCancel: { + Task { await self.cancelPerformanceExercise(gate) } + } + } + + private func runPerformanceMetricsExercise( + pids: Set, + gate: ProcessPerformanceExerciseGate + ) async -> (proof: ProcessPerformanceCaptureProof, sharedResult: Bool)? { + let primary = Task { + await ProcessPerformanceExerciseContext.$isListenerExerciseRequest.withValue(true) { + await self.snapshot(pids: pids, maximumAge: 0) + } + } + guard await gate.waitForGeneration() != nil else { + await cancelPerformanceExercise(gate) + return nil + } + let secondary = Task { + await ProcessPerformanceExerciseContext.$isListenerExerciseRequest.withValue(true) { + await self.snapshot(pids: pids, maximumAge: 0) + } + } + guard await gate.waitForJoinCount(1) else { + await cancelPerformanceExercise(gate) + _ = await primary.value + _ = await secondary.value + return nil + } + guard !Task.isCancelled else { + await cancelPerformanceExercise(gate) + return nil + } + await gate.releaseCapture() + let primaryResult = await primary.value + let secondaryResult = await secondary.value + guard !Task.isCancelled else { + await cancelPerformanceExercise(gate) + return nil + } + let exercise = performanceExercise + await gate.finish() + performanceExercise = nil + guard let proof = exercise?.proof else { return nil } + return (proof, primaryResult == secondaryResult) + } + + private func cancelPerformanceExercise(_ gate: ProcessPerformanceExerciseGate) async { + guard performanceExercise?.gate === gate else { return } + await gate.releaseCapture() + await gate.finish() + performanceExercise = nil + } + + private func finishCapture( + _ completed: InFlightScan, + captureResult: ( + scan: PortLsofScanResult, + proof: ProcessPerformanceCaptureProof + ) + ) async { + let storedAt = await now() + guard inFlight?.id == completed.id else { return } + if performanceExercise?.captureID == completed.id { + performanceExercise?.proof = captureResult.proof + } + cached = CachedScan( + requestedPIDs: completed.requestedPIDs, + scan: captureResult.scan, + storedAt: storedAt, + metricsToken: completed.metricsToken + ) + inFlight = nil + metrics.lsofCompleted(completed.metricsToken, proof: captureResult.proof) + } + + private func validCachedScan( + covering requestedPIDs: Set, + maximumAge: TimeInterval, + now: Date + ) -> CachedScan? { + guard let cached, + cached.requestedPIDs.isSuperset(of: requestedPIDs), + now.timeIntervalSince(cached.storedAt) <= max(0, maximumAge) else { + return nil + } + return cached + } +} diff --git a/Sources/PortScanner+SnapshotProjection.swift b/Sources/PortScanner+SnapshotProjection.swift new file mode 100644 index 000000000000..d3db576cd9ea --- /dev/null +++ b/Sources/PortScanner+SnapshotProjection.swift @@ -0,0 +1,201 @@ +import CmuxFoundation +import Foundation +import os + +extension PortScanner { +#if DEBUG + static func acceptsResult( + currentRevision: UInt64, + expectedRevision: UInt64, + staleMetric: ProcessStaleRejection, + metrics: ProcessPerformanceMetrics = .shared + ) -> Bool { + guard currentRevision == expectedRevision else { + metrics.recordStaleRejection(staleMetric) + return false + } + return true + } +#else + static func acceptsResult( + currentRevision: UInt64, + expectedRevision: UInt64, + staleMetric: ProcessStaleRejection + ) -> Bool { + currentRevision == expectedRevision + } +#endif + + // MARK: - Process helpers + + static func expandAgentProcessTree( + agentPIDsByWorkspace: [UUID: Set], + processSnapshot: CmuxTopProcessSnapshot + ) -> [Int: Set] { + let normalizedRoots = agentPIDsByWorkspace.reduce(into: [UUID: Set]()) { partial, item in + let valid = Set(item.value.filter { $0 > 0 }) + guard !valid.isEmpty else { return } + partial[item.key] = valid + } + guard !normalizedRoots.isEmpty else { return [:] } + + var pidToWorkspaces: [Int: Set] = [:] + for (workspaceId, roots) in normalizedRoots { + for pid in processSnapshot.expandedPIDs(rootPIDs: roots) { + pidToWorkspaces[pid, default: []].insert(workspaceId) + } + } + return pidToWorkspaces + } + + static func pidToTTY( + panelSnapshot: [PanelKey: String], + processSnapshot: CmuxTopProcessSnapshot + ) -> [Int: String] { + var result: [Int: String] = [:] + for tty in Set(panelSnapshot.values) { + for pid in processSnapshot.pids(forTTYName: tty) { + result[pid] = tty + } + } + return result + } + + static func scanResult( + panelSnapshot: [PanelKey: String], + pidToTTY: [Int: String], + agentPidToWorkspaces: [Int: Set], + pidToPorts: [Int: Set] + ) -> (panelResults: [(PanelKey, [Int])], agentPortsByWorkspace: [UUID: Set]) { + var portsByTTY: [String: Set] = [:] + for (pid, ports) in pidToPorts { + if let tty = pidToTTY[pid] { + portsByTTY[tty, default: []].formUnion(ports) + } + } + let panelResults = panelSnapshot.map { key, tty in + (key, Array(portsByTTY[tty] ?? []).sorted()) + } + return ( + panelResults, + agentPortsByWorkspace( + agentPidToWorkspaces: agentPidToWorkspaces, + pidToPorts: pidToPorts + ) + ) + } + + static func agentPortsByWorkspace( + agentPidToWorkspaces: [Int: Set], + pidToPorts: [Int: Set] + ) -> [UUID: Set] { + var result: [UUID: Set] = [:] + for (pid, ports) in pidToPorts { + for workspaceID in agentPidToWorkspaces[pid] ?? [] { + result[workspaceID, default: []].formUnion(ports) + } + } + return result + } + + static func scanListeningPortsWithPerformanceProof( + pids: Set + ) -> (scan: PortLsofScanResult, proof: ProcessPerformanceCaptureProof) { + guard !pids.isEmpty else { + return ( + PortLsofScanResult(values: [:], globallyComplete: true, incompletePIDs: []), + .libproc + ) + } + var result: [Int: Set] = [:] + var incompletePIDs: Set = [] + for pid in pids.sorted() where pid > 0 { + let rawPID = pid_t(pid) + errno = 0 + let requiredBytes = proc_pidinfo(rawPID, PROC_PIDLISTFDS, 0, nil, 0) + guard requiredBytes > 0 else { + if errno != 0, PIDPresence.current(pid: rawPID) != .absent { + incompletePIDs.insert(pid) + } + continue + } + + // Leave spare entries for descriptors opened between sizing and + // the second syscall. A truncated list is refreshed on the next + // bounded scan and never blocks another consumer. + let requiredCount = Int(requiredBytes) / MemoryLayout.stride + let capacity = max(1, requiredCount + 16) + var descriptors = [proc_fdinfo](repeating: proc_fdinfo(), count: capacity) + let bufferBytes = Int32(capacity * MemoryLayout.stride) + errno = 0 + let usedBytes = proc_pidinfo( + rawPID, + PROC_PIDLISTFDS, + 0, + &descriptors, + bufferBytes + ) + guard usedBytes > 0 else { + if errno != 0, PIDPresence.current(pid: rawPID) != .absent { + incompletePIDs.insert(pid) + } + continue + } + if usedBytes >= bufferBytes { + incompletePIDs.insert(pid) + } + + let descriptorCount = min( + descriptors.count, + Int(usedBytes) / MemoryLayout.stride + ) + for descriptor in descriptors.prefix(descriptorCount) + where descriptor.proc_fdtype == UInt32(PROX_FDTYPE_SOCKET) { + var socketInfo = socket_fdinfo() + errno = 0 + let infoBytes = proc_pidfdinfo( + rawPID, + descriptor.proc_fd, + PROC_PIDFDSOCKETINFO, + &socketInfo, + Int32(MemoryLayout.size) + ) + guard infoBytes == MemoryLayout.size else { + if PIDPresence.current(pid: rawPID) != .absent { + incompletePIDs.insert(pid) + } + continue + } + guard let port = listeningTCPPort(from: socketInfo) else { + continue + } + result[pid, default: []].insert(port) + } + } + return ( + PortLsofScanResult( + values: result, + globallyComplete: true, + incompletePIDs: incompletePIDs + ), + .libproc + ) + } + + static func scanListeningPorts(pids: Set) -> [Int: Set] { + scanListeningPortsWithPerformanceProof(pids: pids).scan.values + } + + static func listeningTCPPort(from socketInfo: socket_fdinfo) -> Int? { + guard socketInfo.psi.soi_kind == SOCKINFO_TCP, + socketInfo.psi.soi_protocol == IPPROTO_TCP, + socketInfo.psi.soi_proto.pri_tcp.tcpsi_state == TSI_S_LISTEN else { + return nil + } + let networkPort = UInt16( + truncatingIfNeeded: socketInfo.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport + ) + let port = Int(UInt16(bigEndian: networkPort)) + return port > 0 ? port : nil + } +} diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift index 0f592637fb85..f08dbea8361f 100644 --- a/Sources/PortScanner.swift +++ b/Sources/PortScanner.swift @@ -2,23 +2,73 @@ import CmuxCore import CmuxFoundation import Darwin import Foundation +import os -/// Batched port scanner that replaces per-shell `ps + lsof` scanning. +/// Batched port scanner that shares bounded process and listener snapshots. /// /// Each shell sends a lightweight `report_tty` + `ports_kick` over the socket. /// PortScanner coalesces kicks across all panels, then runs a single -/// `ps -t ` + `lsof -p ` covering every panel that needs scanning. +/// A single process graph and listener scan cover every panel that needs scanning. /// /// Kick → coalesce → burst flow: /// 1. `kick()` adds panel to `pendingKicks` set /// 2. If no burst is active, starts a 200ms coalesce timer -/// 3. Coalesce fires → snapshots pending set → starts burst of 6 scans +/// 3. Coalesce fires → snapshots pending set → starts a four-scan burst /// 4. New kicks during burst merge into the active burst /// 5. After last scan, if new kicks arrived, start a new coalesce cycle final class PortScanner: @unchecked Sendable { - static let shared = PortScanner() + static let shared = PortScanner(useSharedSnapshots: true) + + final class ResultGenerationGate: @unchecked Sendable { + private struct State { + var panelRevision: UInt64 = 0 + var agentRevisionByWorkspace: [UUID: UInt64] = [:] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + func advancePanel(to revision: UInt64) { + state.withLock { $0.panelRevision = revision } + } + + func advanceAgent(workspaceId: UUID, to revision: UInt64) { + state.withLock { $0.agentRevisionByWorkspace[workspaceId] = revision } + } + + @MainActor + func applyPanel(ifCurrent revision: UInt64, _ callback: () -> Result) -> Result? { + state.withLock { + guard PortScanner.acceptsResult( + currentRevision: $0.panelRevision, + expectedRevision: revision, + staleMetric: .portPanelRevision + ) else { return nil } + return callback() + } + } + + @MainActor + func applyAgent( + workspaceId: UUID, + ifCurrent revision: UInt64, + _ callback: () -> Result + ) -> Result? { + state.withLock { + guard PortScanner.acceptsResult( + currentRevision: $0.agentRevisionByWorkspace[workspaceId, default: 0], + expectedRevision: revision, + staleMetric: .portAgentRevision + ) else { return nil } + return callback() + } + } + } let commandRunner: any CommandRunning + private let useSharedSnapshots: Bool + private let portScanSnapshotStore = PortScanSnapshotStore(captureWithEvidenceAndProof: { pids in + PortScanner.scanListeningPortsWithPerformanceProof(pids: pids) + }) /// Callback delivers `(workspaceId, panelId, ports)` on the main actor. @MainActor var onPortsUpdated: (@MainActor (_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? @@ -60,23 +110,27 @@ final class PortScanner: @unchecked Sendable { /// Each scan fires at this absolute offset; the recursive scheduler /// converts to relative delays between consecutive scans. - private static let burstOffsets: [Double] = [0.5, 1.5, 3, 5, 7.5, 10] + private static let burstOffsets: [Double] = [0.5, 1.5, 4, 10] private static let agentRescanInterval: TimeInterval = 2 + private static let panelPortScanMaximumAge: TimeInterval = 0.5 + private static let agentPortScanMaximumAge = agentRescanInterval // MARK: - Public API init( - commandRunner: any CommandRunning = CommandRunner(), + commandRunner: (any CommandRunning)? = nil, processIdentityProvider: @escaping @Sendable (pid_t) -> AgentPIDProcessIdentity? = { AgentPIDProcessIdentity(pid: $0) }, processPresenceProvider: @escaping @Sendable (pid_t) -> PIDPresence = { PIDPresence.current(pid: $0) - } + }, + useSharedSnapshots: Bool = false ) { - self.commandRunner = commandRunner + self.commandRunner = commandRunner ?? CommandRunner() self.processIdentityProvider = processIdentityProvider self.processPresenceProvider = processPresenceProvider + self.useSharedSnapshots = useSharedSnapshots } @MainActor @@ -159,6 +213,12 @@ final class PortScanner: @unchecked Sendable { } } + nonisolated func performanceMetricsExercise( + pids: Set + ) async -> (proof: ProcessPerformanceCaptureProof, sharedResult: Bool)? { + await portScanSnapshotStore.performanceMetricsExercise(pids: pids) + } + // MARK: - Coalesce + Burst private func startCoalesce() { @@ -247,18 +307,36 @@ final class PortScanner: @unchecked Sendable { ) async { let workspaceIds = Set(panelSnapshot.keys.map(\.workspaceId)) - // Build TTY set (deduplicated). let uniqueTTYs = Set(panelSnapshot.values) let ttyList = uniqueTTYs.joined(separator: ",") - - // 1. ps -t tty1,tty2,... -o pid=,tty= - async let agentProcessScanTask = expandAgentProcessTree( - agentRootsByWorkspace: agentRootsByWorkspace + let psScan: (values: [Int: String], completeness: PortScanCompleteness) + let agentProcessScan: ( + values: [Int: Set], + completenessByWorkspace: [UUID: PortScanCompleteness] ) - let psScan = ttyList.isEmpty - ? (values: [Int: String](), completeness: PortScanCompleteness.complete) - : await runPS(ttyList: ttyList) - let agentProcessScan = await agentProcessScanTask + if useSharedSnapshots { + let initialRoots = validateAgentRoots(agentRootsByWorkspace) + let snapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: .basic, + maximumAge: 0.5, + consumer: .portScannerPanel + ) + psScan = ( + Self.pidToTTY(panelSnapshot: panelSnapshot, processSnapshot: snapshot), + snapshot.isComplete ? .complete : .incomplete + ) + agentProcessScan = sharedAgentProcessScan( + rootsByWorkspace: agentRootsByWorkspace, + initialCompleteness: initialRoots.completenessByWorkspace, + processSnapshot: snapshot + ) + } else { + async let agentScan = expandAgentProcessTree(agentRootsByWorkspace: agentRootsByWorkspace) + psScan = ttyList.isEmpty + ? ([:], .complete) + : await runPS(ttyList: ttyList) + agentProcessScan = await agentScan + } let pidToTTY = psScan.values let capturedPanelPIDs = capturePIDIdentities(Set(pidToTTY.keys)) let capturedAgentPIDs = captureAgentPIDIdentities( @@ -302,26 +380,54 @@ final class PortScanner: @unchecked Sendable { return } - // 2. lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn - let pidsCsv = allPids.sorted().map(String.init).joined(separator: ",") - let lsofScan = await runLsof(pidsCsv: pidsCsv) + let lsofScan = await listenerScan( + pids: allPids, + maximumAge: Self.panelPortScanMaximumAge + ) let pidToPorts = lsofScan.values - async let finalizedAgentPIDTask = finalizeAgentPIDOwnership( - rootsByWorkspace: agentRootsByWorkspace, - capturedOwnershipByPID: agentOwnershipBeforeLsof, - capturedIdentitiesByPID: capturedAgentPIDs.identitiesByPID, - workspaceIds: workspaceIds + let finalizedAgentPIDs: ( + ownershipByPID: [Int: Set], + completenessByWorkspace: [UUID: PortScanCompleteness] ) - let refreshedPanelProcessScan = capturedPanelPIDs.identitiesByPID.isEmpty - ? (values: [Int: String](), completeness: PortScanCompleteness.complete) - : await runPS(ttyList: ttyList) + let refreshedPanelProcessScan: ( + values: [Int: String], + completeness: PortScanCompleteness + ) + if useSharedSnapshots { + let snapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: .basic, + maximumAge: 0, + consumer: .portScannerPanel + ) + refreshedPanelProcessScan = ( + Self.pidToTTY(panelSnapshot: panelSnapshot, processSnapshot: snapshot), + snapshot.isComplete ? .complete : .incomplete + ) + finalizedAgentPIDs = finalizeSharedAgentPIDOwnership( + rootsByWorkspace: agentRootsByWorkspace, + capturedOwnershipByPID: agentOwnershipBeforeLsof, + capturedIdentitiesByPID: capturedAgentPIDs.identitiesByPID, + workspaceIds: workspaceIds, + processSnapshot: snapshot + ) + } else { + async let finalized = finalizeAgentPIDOwnership( + rootsByWorkspace: agentRootsByWorkspace, + capturedOwnershipByPID: agentOwnershipBeforeLsof, + capturedIdentitiesByPID: capturedAgentPIDs.identitiesByPID, + workspaceIds: workspaceIds + ) + refreshedPanelProcessScan = capturedPanelPIDs.identitiesByPID.isEmpty + ? ([:], .complete) + : await runPS(ttyList: ttyList) + finalizedAgentPIDs = await finalized + } let revalidatedPanelPIDs = revalidatePanelPIDOwnership( capturedPIDToTTY: pidToTTY, capturedIdentitiesByPID: capturedPanelPIDs.identitiesByPID, refreshedPIDToTTY: refreshedPanelProcessScan.values ) let validPIDToTTY = revalidatedPanelPIDs.values - let finalizedAgentPIDs = await finalizedAgentPIDTask let agentOwnershipByPID = finalizedAgentPIDs.ownershipByPID // 3. Join: PID→TTY + PID→ports → TTY→ports @@ -523,9 +629,27 @@ final class PortScanner: @unchecked Sendable { let agentRootsByWorkspace = request.rootInput.rootsByWorkspace Task { [weak self] in guard let self else { return } - let agentProcessScan = await self.expandAgentProcessTree( - agentRootsByWorkspace: agentRootsByWorkspace + let agentProcessScan: ( + values: [Int: Set], + completenessByWorkspace: [UUID: PortScanCompleteness] ) + if self.useSharedSnapshots { + let initialRoots = self.validateAgentRoots(agentRootsByWorkspace) + let snapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: .basic, + maximumAge: 0.5, + consumer: .portScannerAgent + ) + agentProcessScan = self.sharedAgentProcessScan( + rootsByWorkspace: agentRootsByWorkspace, + initialCompleteness: initialRoots.completenessByWorkspace, + processSnapshot: snapshot + ) + } else { + agentProcessScan = await self.expandAgentProcessTree( + agentRootsByWorkspace: agentRootsByWorkspace + ) + } let capturedAgentPIDs = self.captureAgentPIDIdentities( ownershipByPID: agentProcessScan.values, workspaceIds: request.workspaceIds @@ -546,18 +670,36 @@ final class PortScanner: @unchecked Sendable { return } - let pidsCsv = capturedAgentPIDs.ownershipByPID.keys - .sorted() - .map(String.init) - .joined(separator: ",") - let lsofScan = await self.runLsof(pidsCsv: pidsCsv) + let lsofScan = await self.listenerScan( + pids: Set(capturedAgentPIDs.ownershipByPID.keys), + maximumAge: Self.agentPortScanMaximumAge + ) let pidToPorts = lsofScan.values - let finalizedAgentPIDs = await self.finalizeAgentPIDOwnership( - rootsByWorkspace: agentRootsByWorkspace, - capturedOwnershipByPID: capturedAgentPIDs.ownershipByPID, - capturedIdentitiesByPID: capturedAgentPIDs.identitiesByPID, - workspaceIds: request.workspaceIds + let finalizedAgentPIDs: ( + ownershipByPID: [Int: Set], + completenessByWorkspace: [UUID: PortScanCompleteness] ) + if self.useSharedSnapshots { + let snapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: .basic, + maximumAge: 0, + consumer: .portScannerAgent + ) + finalizedAgentPIDs = self.finalizeSharedAgentPIDOwnership( + rootsByWorkspace: agentRootsByWorkspace, + capturedOwnershipByPID: capturedAgentPIDs.ownershipByPID, + capturedIdentitiesByPID: capturedAgentPIDs.identitiesByPID, + workspaceIds: request.workspaceIds, + processSnapshot: snapshot + ) + } else { + finalizedAgentPIDs = await self.finalizeAgentPIDOwnership( + rootsByWorkspace: agentRootsByWorkspace, + capturedOwnershipByPID: capturedAgentPIDs.ownershipByPID, + capturedIdentitiesByPID: capturedAgentPIDs.identitiesByPID, + workspaceIds: request.workspaceIds + ) + } let agentOwnershipByPID = finalizedAgentPIDs.ownershipByPID var agentPortsByWorkspace: [UUID: Set] = [:] for (pid, ports) in pidToPorts { @@ -652,6 +794,81 @@ final class PortScanner: @unchecked Sendable { ) } + private func listenerScan( + pids: Set, + maximumAge: TimeInterval + ) async -> PortLsofScanResult { + guard useSharedSnapshots else { + let csv = pids.sorted().map(String.init).joined(separator: ",") + return await runLsof(pidsCsv: csv) + } + return await portScanSnapshotStore.evidencedSnapshot( + pids: pids, + maximumAge: maximumAge + ) + } + + private func sharedAgentProcessScan( + rootsByWorkspace: [UUID: Set], + initialCompleteness: [UUID: PortScanCompleteness], + processSnapshot: CmuxTopProcessSnapshot + ) -> (values: [Int: Set], completenessByWorkspace: [UUID: PortScanCompleteness]) { + let finalRoots = validateAgentRoots(rootsByWorkspace) + let rootPIDs = finalRoots.values.mapValues { Set($0.map(\.pid)) } + var completeness = combineAgentCompleteness( + initialCompleteness, + finalRoots.completenessByWorkspace, + workspaceIds: Set(rootsByWorkspace.keys) + ) + if !processSnapshot.isComplete { + for workspaceID in finalRoots.values.keys { + completeness[workspaceID] = .incomplete + } + } + return ( + Self.expandAgentProcessTree( + agentPIDsByWorkspace: rootPIDs, + processSnapshot: processSnapshot + ), + completeness + ) + } + + private func finalizeSharedAgentPIDOwnership( + rootsByWorkspace: [UUID: Set], + capturedOwnershipByPID: [Int: Set], + capturedIdentitiesByPID: [Int: AgentPIDProcessIdentity], + workspaceIds: Set, + processSnapshot: CmuxTopProcessSnapshot + ) -> (ownershipByPID: [Int: Set], completenessByWorkspace: [UUID: PortScanCompleteness]) { + let finalRoots = validateAgentRoots(rootsByWorkspace) + let rootPIDs = finalRoots.values.mapValues { Set($0.map(\.pid)) } + let finalOwnership = Self.expandAgentProcessTree( + agentPIDsByWorkspace: rootPIDs, + processSnapshot: processSnapshot + ) + let fenced = capturedOwnershipByPID.reduce(into: [Int: Set]()) { result, item in + let retained = item.value.intersection(finalOwnership[item.key] ?? []) + if !retained.isEmpty { result[item.key] = retained } + } + let identities = revalidateAgentPIDIdentities( + ownershipByPID: fenced, + identitiesByPID: capturedIdentitiesByPID, + workspaceIds: workspaceIds + ) + var completeness = combineAgentCompleteness( + finalRoots.completenessByWorkspace, + identities.completenessByWorkspace, + workspaceIds: workspaceIds + ) + if !processSnapshot.isComplete { + for workspaceID in finalRoots.values.keys { + completeness[workspaceID] = .incomplete + } + } + return (identities.ownershipByPID, completeness) + } + private func agentRevisionSnapshot(for workspaceIds: Set) -> [UUID: UInt64] { workspaceIds.reduce(into: [UUID: UInt64]()) { partial, workspaceId in partial[workspaceId] = agentRevisionByWorkspace[workspaceId, default: 0] diff --git a/Sources/ProcessDetectedResumeIndexes.swift b/Sources/ProcessDetectedResumeIndexes.swift index 8f41d170faa1..465c53db8d75 100644 --- a/Sources/ProcessDetectedResumeIndexes.swift +++ b/Sources/ProcessDetectedResumeIndexes.swift @@ -6,24 +6,46 @@ struct ProcessDetectedResumeIndexes: Sendable { static func load( homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default + fileManager: FileManager = .default, + snapshotStore: CmuxTopProcessSnapshotStore = .shared ) async -> ProcessDetectedResumeIndexes { - await Task.detached(priority: .utility) { - loadSynchronously(homeDirectory: homeDirectory, fileManager: fileManager, maximumSnapshotAge: 5) + let processSnapshot = await snapshotStore.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 5, + consumer: .processDetectedResume + ) + return await Task.detached(priority: .utility) { + loadSynchronously( + homeDirectory: homeDirectory, + fileManager: fileManager, + processSnapshot: processSnapshot + ) }.value } + /// Termination-only fallback. App shutdown must persist the final restorable + /// state before returning to AppKit, so this one raw capture cannot await the + /// actor; the compatibility seam records it in the shared proof metrics. static func loadSynchronously( homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, - maximumSnapshotAge: TimeInterval? = nil + fileManager: FileManager = .default + ) -> ProcessDetectedResumeIndexes { + let processSnapshot = CmuxTopProcessSnapshot.captureSynchronouslyForCompatibility( + includeProcessDetails: true + ) + return loadSynchronously( + homeDirectory: homeDirectory, + fileManager: fileManager, + processSnapshot: processSnapshot + ) + } + + static func loadSynchronously( + homeDirectory: String, + fileManager: FileManager, + processSnapshot: CmuxTopProcessSnapshot ) -> ProcessDetectedResumeIndexes { let capturedAt = Date().timeIntervalSince1970 - let processSnapshot = if let maximumSnapshotAge { - CmuxTopProcessSnapshot.captureCached(includeProcessDetails: true, maximumAge: maximumSnapshotAge) - } else { - CmuxTopProcessSnapshot.capture(includeProcessDetails: true) - } let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) let detectedSnapshots = RestorableAgentSessionIndex.processDetectedSnapshots( registry: registry, diff --git a/Sources/ProcessPerformanceCaptureProof.swift b/Sources/ProcessPerformanceCaptureProof.swift new file mode 100644 index 000000000000..76d02e25bdf3 --- /dev/null +++ b/Sources/ProcessPerformanceCaptureProof.swift @@ -0,0 +1,51 @@ +nonisolated enum ProcessSnapshotConsumer: String, Sendable { + case memoryGuardrail = "memory_guardrail" + case portScannerAgent = "port_scanner.agent" + case portScannerPanel = "port_scanner.panel" + case processDetectedResume = "process_detected_resume" + case sentry = "sentry" + case sharedLiveAgentIndex = "shared_live_agent_index" + case systemTop = "system_top" + case performanceExercisePrimary = "performance_exercise.primary" + case performanceExerciseSecondary = "performance_exercise.secondary" + case unspecified +} + +nonisolated enum ProcessStaleRejection: String, Sendable { + case portAgentAcknowledgement = "port_agent_acknowledgement" + case portAgentRevision = "port_agent_revision" + case portPanelRevision = "port_panel_revision" +} + +nonisolated enum ProcessSnapshotReuseSource: String, Sendable { + case cache + case inFlight = "in_flight" +} + +nonisolated enum ProcessLsofReuseSource: String, Sendable { + case cache + case inFlight = "in_flight" +} + +nonisolated enum ProcessMeasuredOperation: String, Sendable { + case portApply = "port.apply" + case portFilter = "port.filter" + case restorableApply = "restorable.apply" + case restorableLoad = "restorable.load" + case vaultFilter = "vault.filter" +} + +nonisolated enum ProcessPerformanceCaptureBackend: String, Sendable { + case libproc + case subprocess +} + +nonisolated struct ProcessPerformanceCaptureProof: Sendable, Equatable { + let backend: ProcessPerformanceCaptureBackend + let processLaunchCount: Int + + static let libproc = ProcessPerformanceCaptureProof( + backend: .libproc, + processLaunchCount: 0 + ) +} diff --git a/Sources/ProcessPerformanceExerciseGate.swift b/Sources/ProcessPerformanceExerciseGate.swift new file mode 100644 index 000000000000..950d289f40c0 --- /dev/null +++ b/Sources/ProcessPerformanceExerciseGate.swift @@ -0,0 +1,169 @@ +actor ProcessPerformanceExerciseGate { + private var captureReleased = false + private var captureWaiters: [CheckedContinuation] = [] + private var finished = false + private var finishWaiters: [CheckedContinuation] = [] + private var generation: UInt64? + private var generationWaiters: [UInt64: CheckedContinuation] = [:] + private var cancelledGenerationWaiters: Set = [] + private var joinCount = 0 + private var joinTargets: [UInt64: (Int, CheckedContinuation)] = [:] + private var cancelledJoinWaiters: Set = [] + private var nextWaiterID: UInt64 = 0 + + func waitForCaptureRelease() async { + guard !captureReleased else { return } + await withCheckedContinuation { captureWaiters.append($0) } + } + + func releaseCapture() { + captureReleased = true + let waiters = captureWaiters + captureWaiters.removeAll(keepingCapacity: false) + waiters.forEach { $0.resume() } + } + + func waitUntilFinished() async { + guard !finished else { return } + await withCheckedContinuation { finishWaiters.append($0) } + } + + func finish() { + finished = true + let waiters = finishWaiters + finishWaiters.removeAll(keepingCapacity: false) + waiters.forEach { $0.resume() } + generationWaiters.values.forEach { $0.resume(returning: nil) } + generationWaiters.removeAll(keepingCapacity: false) + joinTargets.values.forEach { $0.1.resume(returning: false) } + joinTargets.removeAll(keepingCapacity: false) + } + + func recordGeneration(_ generation: UInt64) { + guard self.generation == nil else { return } + self.generation = generation + let waiters = generationWaiters.values + generationWaiters.removeAll(keepingCapacity: false) + waiters.forEach { $0.resume(returning: generation) } + } + + func recordJoin() { + joinCount += 1 + var remaining: [UInt64: (Int, CheckedContinuation)] = [:] + for (id, (target, continuation)) in joinTargets { + if joinCount >= target { + continuation.resume(returning: true) + } else { + remaining[id] = (target, continuation) + } + } + joinTargets = remaining + } + + func waitForGeneration() async -> UInt64? { + if let generation { return generation } + let id = makeWaiterID() + return await Self.withTimeout { await self.waitForGenerationSignal(id: id) } + } + + func waitForJoinCount(_ target: Int) async -> Bool { + if joinCount >= target { return true } + let id = makeWaiterID() + return await Self.withTimeout { + await self.waitForJoinSignal(id: id, target: target) + } ?? false + } + + private nonisolated static func withTimeout( + _ operation: @escaping @Sendable () async -> Value? + ) async -> Value? { + await withTaskGroup(of: Value?.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(for: .seconds(10)) + return nil + } + let result = await group.next() ?? nil + group.cancelAll() + return result + } + } + + private func makeWaiterID() -> UInt64 { + nextWaiterID &+= 1 + return nextWaiterID + } + + private func waitForGenerationSignal(id: UInt64) async -> UInt64? { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + Task { self.installGenerationWaiter(id: id, continuation: continuation) } + } + } onCancel: { + Task { await self.cancelGenerationWaiter(id: id) } + } + } + + private func installGenerationWaiter( + id: UInt64, + continuation: CheckedContinuation + ) { + if let generation { + continuation.resume(returning: generation) + } else if finished || cancelledGenerationWaiters.remove(id) != nil { + continuation.resume(returning: nil) + } else { + generationWaiters[id] = continuation + } + } + + private func cancelGenerationWaiter(id: UInt64) { + if let continuation = generationWaiters.removeValue(forKey: id) { + continuation.resume(returning: nil) + } else { + cancelledGenerationWaiters.insert(id) + } + } + + private func waitForJoinSignal(id: UInt64, target: Int) async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + Task { + self.installJoinWaiter( + id: id, + target: target, + continuation: continuation + ) + } + } + } onCancel: { + Task { await self.cancelJoinWaiter(id: id) } + } + } + + private func installJoinWaiter( + id: UInt64, + target: Int, + continuation: CheckedContinuation + ) { + if joinCount >= target { + continuation.resume(returning: true) + } else if finished || cancelledJoinWaiters.remove(id) != nil { + continuation.resume(returning: false) + } else { + joinTargets[id] = (target, continuation) + } + } + + private func cancelJoinWaiter(id: UInt64) { + if let continuation = joinTargets.removeValue(forKey: id)?.1 { + continuation.resume(returning: false) + } else { + cancelledJoinWaiters.insert(id) + } + } +} + +enum ProcessPerformanceExerciseContext { + @TaskLocal static var isListenerExerciseRequest = false +} diff --git a/Sources/ProcessPerformanceMetrics.swift b/Sources/ProcessPerformanceMetrics.swift new file mode 100644 index 000000000000..5cf1c35897f4 --- /dev/null +++ b/Sources/ProcessPerformanceMetrics.swift @@ -0,0 +1,499 @@ +import CmuxFoundation +import Foundation +import os + +nonisolated struct ProcessPerformanceDurationMetrics: Sendable, Equatable { + var count = 0 + var totalMilliseconds = 0.0 + var maximumMilliseconds = 0.0 + var lastMilliseconds = 0.0 +} + +nonisolated struct ProcessPerformanceReuseMetrics: Sendable, Equatable { + var cache = 0 + var inFlight = 0 +} + +nonisolated struct ProcessPerformanceSnapshotCaptureMetrics: Sendable, Equatable { + var captureStarted = 0 + var captureCompleted = 0 + var inFlight = 0 + var maximumInFlight = 0 + var lastGeneration: UInt64 = 0 + var duration = ProcessPerformanceDurationMetrics() +} + +nonisolated struct ProcessPerformanceOperationMetrics: Sendable, Equatable { + var started = 0 + var completed = 0 + var inputCount = 0 + var outputCount = 0 + var duration = ProcessPerformanceDurationMetrics() +} + +nonisolated struct ProcessPerformanceGenerationMetrics: Sendable, Equatable { + var requirementsRawValue: UInt8 = 0 + var started = 0 + var completed = 0 + var processCount = 0 + var backend = "pending" + var processLaunches = 0 + var duration = ProcessPerformanceDurationMetrics() +} + +nonisolated struct ProcessPerformanceLsofMetrics: Sendable, Equatable { + // Kept under the legacy `lsof` RPC key for schema compatibility. The + // backend field identifies that these counters now measure libproc scans. + var started = 0 + var completed = 0 + var inFlight = 0 + var maximumInFlight = 0 + var pidCount = 0 + var coalescedRequests = 0 + var backendCounts: [String: Int] = [:] + var processLaunches = 0 + var reuse = ProcessPerformanceReuseMetrics() + var duration = ProcessPerformanceDurationMetrics() +} + +nonisolated struct ProcessPerformanceMetricsSnapshot: Sendable, Equatable { + let enabled: Bool + let measurementEpoch: UInt64 + let resetAtUnixMilliseconds: UInt64 + let processSnapshots: ProcessPerformanceSnapshotCaptureMetrics + let generations: [UInt64: ProcessPerformanceGenerationMetrics] + let requestCountsByConsumer: [String: Int] + let consumerGenerationReuse: [String: [UInt64: ProcessPerformanceReuseMetrics]] + let lsof: ProcessPerformanceLsofMetrics + let staleRejections: [String: Int] + let operations: [String: ProcessPerformanceOperationMetrics] + + var foundationObject: [String: Any] { + return [ + "schema_version": 3, + "enabled": enabled, + "measurement_epoch": NSNumber(value: measurementEpoch), + "reset_at_unix_ms": NSNumber(value: resetAtUnixMilliseconds), + "process_snapshots": [ + "capture_started": processSnapshots.captureStarted, + "capture_completed": processSnapshots.captureCompleted, + "in_flight": processSnapshots.inFlight, + "max_in_flight": processSnapshots.maximumInFlight, + "last_generation": NSNumber(value: processSnapshots.lastGeneration), + "duration_ms": processSnapshots.duration.foundationObject, + "generations": Dictionary(uniqueKeysWithValues: generations.map { generation, metrics in + (String(generation), metrics.foundationObject) + }), + ], + "consumer_generation_reuse": consumerGenerationReuse.mapValues { generations in + Dictionary(uniqueKeysWithValues: generations.map { generation, metrics in + (String(generation), metrics.foundationObject) + }) + }, + "request_counts_by_consumer": requestCountsByConsumer, + "lsof": lsof.foundationObject, + "stale_rejections": staleRejections, + "operations": operations.mapValues(\.foundationObject), + ] + } +} + +private nonisolated extension ProcessPerformanceDurationMetrics { + var foundationObject: [String: Any] { + [ + "count": count, + "total": totalMilliseconds, + "max": maximumMilliseconds, + "last": lastMilliseconds, + ] + } +} + +private nonisolated extension ProcessPerformanceReuseMetrics { + var foundationObject: [String: Any] { + ["cache": cache, "in_flight": inFlight] + } +} + +private nonisolated extension ProcessPerformanceGenerationMetrics { + var foundationObject: [String: Any] { + [ + "requirements_raw_value": Int(requirementsRawValue), + "started": started, + "completed": completed, + "process_count": processCount, + "backend": backend, + "process_launches": processLaunches, + "duration_ms": duration.foundationObject, + ] + } +} + +private nonisolated extension ProcessPerformanceLsofMetrics { + var foundationObject: [String: Any] { + let backend: String + if backendCounts.isEmpty { + backend = "none" + } else if backendCounts.count == 1 { + backend = backendCounts.keys.first ?? "none" + } else { + backend = "mixed" + } + return [ + "backend": backend, + "backend_counts": backendCounts, + "process_launches": processLaunches, + "started": started, + "completed": completed, + "in_flight": inFlight, + "max_in_flight": maximumInFlight, + "pid_count": pidCount, + "coalesced_requests": coalescedRequests, + "reuse": reuse.foundationObject, + "duration_ms": duration.foundationObject, + ] + } +} + +private nonisolated extension ProcessPerformanceOperationMetrics { + var foundationObject: [String: Any] { + [ + "started": started, + "completed": completed, + "input_count": inputCount, + "output_count": outputCount, + "duration_ms": duration.foundationObject, + ] + } +} + +nonisolated struct ProcessPerformanceMetricToken: Sendable { + fileprivate let key: String + fileprivate let epoch: UInt64 + fileprivate let startedAtNanoseconds: UInt64? + fileprivate let inputCount: Int +} + +/// Opt-in, process-wide counters for proving process enumeration is shared and +/// absent from the interactive typing path in an optimized Release build. The +/// counters are disabled by default. The lock protects only small integer +/// updates and JSON snapshots; no process work or UI mutation runs in its +/// critical section. +nonisolated final class ProcessPerformanceMetrics: @unchecked Sendable { + static let shared = ProcessPerformanceMetrics() + + private struct State { + var epoch: UInt64 + var enabled: Bool + var synchronousCaptureGeneration: UInt64 = 0 + var resetAtUnixMilliseconds: UInt64 + var processSnapshots = ProcessPerformanceSnapshotCaptureMetrics() + var generations: [UInt64: ProcessPerformanceGenerationMetrics] = [:] + var requestCountsByConsumer: [String: Int] = [:] + var consumerGenerationReuse: [String: [UInt64: ProcessPerformanceReuseMetrics]] = [:] + var lsof = ProcessPerformanceLsofMetrics() + var staleRejections: [String: Int] = [:] + var operations: [String: ProcessPerformanceOperationMetrics] = [:] + + init( + epoch: UInt64 = 0, + enabled: Bool = false, + resetAtUnixMilliseconds: UInt64 + ) { + self.epoch = epoch + self.enabled = enabled + self.resetAtUnixMilliseconds = resetAtUnixMilliseconds + } + } + + private let state: OSAllocatedUnfairLock + private let enabled: AtomicBooleanGate + private let monotonicNanoseconds: @Sendable () -> UInt64 + private let unixMilliseconds: @Sendable () -> UInt64 + + init( + enabled: Bool = _isDebugAssertConfiguration(), + monotonicNanoseconds: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + }, + unixMilliseconds: @escaping @Sendable () -> UInt64 = { + ProcessPerformanceMetrics.currentUnixMilliseconds() + } + ) { + state = OSAllocatedUnfairLock(initialState: State( + enabled: enabled, + resetAtUnixMilliseconds: unixMilliseconds() + )) + self.enabled = AtomicBooleanGate(enabled) + self.monotonicNanoseconds = monotonicNanoseconds + self.unixMilliseconds = unixMilliseconds + } + + func reset(enable: Bool = true) { + state.withLock { state in + state = State( + epoch: Self.nextMeasurementEpoch(after: state.epoch), + enabled: enable, + resetAtUnixMilliseconds: unixMilliseconds() + ) + } + enabled.storeRelease(enable) + } + + func disable() { + enabled.storeRelease(false) + state.withLock { $0.enabled = false } + } + + func snapshot() -> ProcessPerformanceMetricsSnapshot { + state.withLock { state in + ProcessPerformanceMetricsSnapshot( + enabled: state.enabled, + measurementEpoch: state.epoch, + resetAtUnixMilliseconds: state.resetAtUnixMilliseconds, + processSnapshots: state.processSnapshots, + generations: state.generations, + requestCountsByConsumer: state.requestCountsByConsumer, + consumerGenerationReuse: state.consumerGenerationReuse, + lsof: state.lsof, + staleRejections: state.staleRejections, + operations: state.operations + ) + } + } + + func recordProcessSnapshotRequest(consumer: ProcessSnapshotConsumer) { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.requestCountsByConsumer[consumer.rawValue, default: 0] += 1 + } + } + + func nextSynchronousCaptureGeneration() -> UInt64 { + guard enabled.loadRelaxed() else { return 0 } + return state.withLock { state -> UInt64 in + guard state.enabled else { return 0 } + state.synchronousCaptureGeneration &+= 1 + return (UInt64(1) << 63) | state.synchronousCaptureGeneration + } + } + + func processSnapshotCaptureStarted( + generation: UInt64, + requirementsRawValue: UInt8 + ) -> ProcessPerformanceMetricToken { + guard enabled.loadRelaxed() else { + return disabledToken(key: String(generation), inputCount: 0) + } + return state.withLock { state in + guard state.enabled else { + return disabledToken(key: String(generation), inputCount: 0) + } + state.processSnapshots.captureStarted += 1 + state.processSnapshots.inFlight += 1 + state.processSnapshots.maximumInFlight = max( + state.processSnapshots.maximumInFlight, + state.processSnapshots.inFlight + ) + state.processSnapshots.lastGeneration = generation + state.generations[generation, default: ProcessPerformanceGenerationMetrics()] + .requirementsRawValue = requirementsRawValue + state.generations[generation, default: ProcessPerformanceGenerationMetrics()].started += 1 + return token( + key: String(generation), + inputCount: 0, + epoch: state.epoch + ) + } + } + + func processSnapshotCaptureCompleted( + _ token: ProcessPerformanceMetricToken, + generation: UInt64, + processCount: Int, + proof: ProcessPerformanceCaptureProof = .libproc + ) { + guard enabled.loadRelaxed(), let startedAt = token.startedAtNanoseconds else { return } + let duration = elapsedMilliseconds(since: startedAt) + return state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + state.processSnapshots.captureCompleted += 1 + state.processSnapshots.inFlight = max(0, state.processSnapshots.inFlight - 1) + state.processSnapshots.duration.record(duration) + state.generations[generation, default: ProcessPerformanceGenerationMetrics()].completed += 1 + state.generations[generation, default: ProcessPerformanceGenerationMetrics()].processCount = processCount + state.generations[generation, default: ProcessPerformanceGenerationMetrics()].backend = + proof.backend.rawValue + state.generations[generation, default: ProcessPerformanceGenerationMetrics()].processLaunches += + max(0, proof.processLaunchCount) + state.generations[generation, default: ProcessPerformanceGenerationMetrics()].duration.record(duration) + } + } + + func recordProcessSnapshotReuse( + consumer: ProcessSnapshotConsumer, + generation: UInt64, + source: ProcessSnapshotReuseSource, + token: ProcessPerformanceMetricToken + ) { + guard enabled.loadRelaxed(), token.startedAtNanoseconds != nil else { return } + return state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + var byGeneration = state.consumerGenerationReuse[consumer.rawValue, default: [:]] + var metrics = byGeneration[generation, default: ProcessPerformanceReuseMetrics()] + switch source { + case .cache: metrics.cache += 1 + case .inFlight: metrics.inFlight += 1 + } + byGeneration[generation] = metrics + state.consumerGenerationReuse[consumer.rawValue] = byGeneration + } + } + + func lsofStarted(pidCount: Int) -> ProcessPerformanceMetricToken { + guard enabled.loadRelaxed() else { + return disabledToken(key: "lsof", inputCount: pidCount) + } + return state.withLock { state in + guard state.enabled else { + return disabledToken(key: "lsof", inputCount: pidCount) + } + state.lsof.started += 1 + state.lsof.inFlight += 1 + state.lsof.maximumInFlight = max(state.lsof.maximumInFlight, state.lsof.inFlight) + state.lsof.pidCount += max(0, pidCount) + return token( + key: "lsof", + inputCount: pidCount, + epoch: state.epoch + ) + } + } + + func lsofCompleted( + _ token: ProcessPerformanceMetricToken, + proof: ProcessPerformanceCaptureProof = .libproc + ) { + guard enabled.loadRelaxed(), let startedAt = token.startedAtNanoseconds else { return } + let duration = elapsedMilliseconds(since: startedAt) + state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + state.lsof.completed += 1 + state.lsof.inFlight = max(0, state.lsof.inFlight - 1) + state.lsof.backendCounts[proof.backend.rawValue, default: 0] += 1 + state.lsof.processLaunches += max(0, proof.processLaunchCount) + state.lsof.duration.record(duration) + } + } + + func recordLsofReuse( + _ source: ProcessLsofReuseSource, + token: ProcessPerformanceMetricToken + ) { + guard enabled.loadRelaxed(), token.startedAtNanoseconds != nil else { return } + state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + switch source { + case .cache: state.lsof.reuse.cache += 1 + case .inFlight: state.lsof.reuse.inFlight += 1 + } + } + } + + func recordLsofCoalescedRequest(token: ProcessPerformanceMetricToken) { + guard enabled.loadRelaxed(), token.startedAtNanoseconds != nil else { return } + state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + state.lsof.coalescedRequests += 1 + } + } + + func recordStaleRejection(_ rejection: ProcessStaleRejection) { + guard enabled.loadRelaxed() else { return } + state.withLock { state in + guard state.enabled else { return } + state.staleRejections[rejection.rawValue, default: 0] += 1 + } + } + + func operationStarted( + _ operation: ProcessMeasuredOperation, + inputCount: Int + ) -> ProcessPerformanceMetricToken { + guard enabled.loadRelaxed() else { + return disabledToken(key: operation.rawValue, inputCount: inputCount) + } + return state.withLock { state in + guard state.enabled else { + return disabledToken(key: operation.rawValue, inputCount: inputCount) + } + state.operations[operation.rawValue, default: ProcessPerformanceOperationMetrics()].started += 1 + state.operations[operation.rawValue, default: ProcessPerformanceOperationMetrics()].inputCount += max(0, inputCount) + return token( + key: operation.rawValue, + inputCount: inputCount, + epoch: state.epoch + ) + } + } + + func operationCompleted( + _ token: ProcessPerformanceMetricToken, + outputCount: Int + ) { + guard enabled.loadRelaxed(), let startedAt = token.startedAtNanoseconds else { return } + let duration = elapsedMilliseconds(since: startedAt) + state.withLock { state in + guard state.enabled, token.epoch == state.epoch else { return } + state.operations[token.key, default: ProcessPerformanceOperationMetrics()].completed += 1 + state.operations[token.key, default: ProcessPerformanceOperationMetrics()].outputCount += max(0, outputCount) + state.operations[token.key, default: ProcessPerformanceOperationMetrics()].duration.record(duration) + } + } + + private func token( + key: String, + inputCount: Int, + epoch: UInt64 + ) -> ProcessPerformanceMetricToken { + ProcessPerformanceMetricToken( + key: key, + epoch: epoch, + startedAtNanoseconds: monotonicNanoseconds(), + inputCount: inputCount + ) + } + + private func disabledToken(key: String, inputCount: Int) -> ProcessPerformanceMetricToken { + ProcessPerformanceMetricToken( + key: key, + epoch: 0, + startedAtNanoseconds: nil, + inputCount: inputCount + ) + } + + private func elapsedMilliseconds(since start: UInt64) -> Double { + let end = monotonicNanoseconds() + return Double(end >= start ? end - start : 0) / 1_000_000 + } + + private static func currentUnixMilliseconds() -> UInt64 { + UInt64(max(0, Date().timeIntervalSince1970 * 1_000)) + } + + static func nextMeasurementEpoch(after epoch: UInt64) -> UInt64 { + let next = epoch &+ 1 + return next == 0 ? 1 : next + } +} + +private nonisolated extension ProcessPerformanceDurationMetrics { + mutating func record(_ milliseconds: Double) { + count += 1 + totalMilliseconds += milliseconds + maximumMilliseconds = max(maximumMilliseconds, milliseconds) + lastMilliseconds = milliseconds + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index b92c5177e971..5ad6e3b75f6e 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -1015,26 +1015,26 @@ struct RestorableAgentSessionIndex: Sendable { ) } - static func loadIncludingProcessDetectedSnapshots( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default - ) async -> RestorableAgentSessionIndex { - await Task.detached(priority: .utility) { + static func loadIncludingProcessDetectedSnapshots(homeDirectory: String = NSHomeDirectory(), fileManager: FileManager = .default, snapshotStore: CmuxTopProcessSnapshotStore = .shared) async -> RestorableAgentSessionIndex { + let processSnapshot = await snapshotStore.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 3, consumer: .processDetectedResume + ) + return await Task.detached(priority: .utility) { loadIncludingProcessDetectedSnapshotsSynchronously( homeDirectory: homeDirectory, - fileManager: fileManager + fileManager: fileManager, processSnapshot: processSnapshot ) }.value } - static func loadIncludingProcessDetectedSnapshotsSynchronously( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default - ) -> RestorableAgentSessionIndex { + static func loadIncludingProcessDetectedSnapshotsSynchronously(homeDirectory: String, fileManager: FileManager, processSnapshot: CmuxTopProcessSnapshot) -> RestorableAgentSessionIndex { let registry = CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) let detectedSnapshots = processDetectedSnapshots( registry: registry, - fileManager: fileManager + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: processSnapshot.sampledAt.timeIntervalSince1970 ) return load( homeDirectory: homeDirectory, diff --git a/Sources/SentryHelper.swift b/Sources/SentryHelper.swift index 0d8d482a7416..b396cde33e07 100644 --- a/Sources/SentryHelper.swift +++ b/Sources/SentryHelper.swift @@ -93,9 +93,10 @@ private func sentryScheduleMemoryContextRefresh( func sentryRefreshMemoryContext(reason: String) async { guard TelemetrySettings.enabledForCurrentLaunch else { return } - let processSnapshot = CmuxTopProcessSnapshot.captureCached( - includeProcessDetails: false, - maximumAge: 2 + let processSnapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: .basic, + maximumAge: 2, + consumer: .sentry ) let pid = Int(Darwin.getpid()) let appProcess = processSnapshot.process(pid: pid) diff --git a/Sources/SharedLiveAgentIndex.swift b/Sources/SharedLiveAgentIndex.swift index b714a1df2671..5e8b7c10e2ba 100644 --- a/Sources/SharedLiveAgentIndex.swift +++ b/Sources/SharedLiveAgentIndex.swift @@ -24,18 +24,43 @@ final class SharedLiveAgentIndex { // Floor between event-driven reloads so chatty hook stores cannot keep the // measured ~350ms-1.8s loader running at near-continuous duty cycle. private static let minEventReloadInterval: TimeInterval = 5.0 + private static let maxEventReloadInterval: TimeInterval = 30.0 + private static let liveAgentsPerReloadIntervalStep = 8 + + static func hookEventReloadInterval(liveAgentCount: Int) -> TimeInterval { + let clampedAgentCount = max(0, liveAgentCount) + let intervalSteps = max( + 1, + (clampedAgentCount + liveAgentsPerReloadIntervalStep - 1) / liveAgentsPerReloadIntervalStep + ) + return min( + maxEventReloadInterval, + TimeInterval(intervalSteps) * minEventReloadInterval + ) + } private var directoryWatchSource: DispatchSourceFileSystemObject? // DispatchSource file watching requires a delivery queue; state hops back to MainActor. private let watchQueue = DispatchQueue(label: "com.cmuxterm.app.sharedLiveAgentIndexWatch") - private let indexLoader: @Sendable () -> SharedLiveAgentIndexLoader.LoadResult + /// Loaders must move synchronous CPU and file-I/O work off the main actor + /// before their first suspension point. This owner awaits the closure directly. + private let indexLoader: @Sendable () async -> SharedLiveAgentIndexLoader.LoadResult private let hookStoreDirectoryProvider: @MainActor () -> String private let dateProvider: @MainActor () -> Date init( - indexLoader: @escaping @Sendable () -> SharedLiveAgentIndexLoader.LoadResult = { - SharedLiveAgentIndexLoader().loadResultSynchronously() + indexLoader: @escaping @Sendable () async -> SharedLiveAgentIndexLoader.LoadResult = { + let processSnapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 3, + consumer: .sharedLiveAgentIndex + ) + return await Task.detached(priority: .utility) { + SharedLiveAgentIndexLoader( + processSnapshotProvider: { processSnapshot } + ).loadResultSynchronously() + }.value }, hookStoreDirectoryProvider: @escaping @MainActor () -> String = { RestorableAgentKind.claude.hookStoreFileURL().deletingLastPathComponent().path @@ -185,9 +210,7 @@ final class SharedLiveAgentIndex { private func reload(forcePublish: Bool) async { let indexLoader = self.indexLoader - let result = await Task.detached(priority: .utility) { - indexLoader() - }.value + let result = await indexLoader() guard !Task.isCancelled else { return } let loadedAt = dateProvider() let hasPendingForkValidations = !pendingForkValidationPanels.isEmpty @@ -221,6 +244,12 @@ final class SharedLiveAgentIndex { processScopeFingerprint: Set, forkValidatedPanels: Set ) { +#if DEBUG + let applyMetricsToken = ProcessPerformanceMetrics.shared.operationStarted( + .restorableApply, + inputCount: newIndex.forkValidationEntries().count + ) +#endif index = newIndex self.loadedAt = loadedAt validatedForkPanels = forkValidatedPanels @@ -231,6 +260,12 @@ final class SharedLiveAgentIndex { validatedMissingForkPanels.removeAll() self.liveAgentProcessFingerprint = liveAgentProcessFingerprint self.processScopeFingerprint = processScopeFingerprint +#if DEBUG + ProcessPerformanceMetrics.shared.operationCompleted( + applyMetricsToken, + outputCount: newIndex.forkValidationEntries().count + ) +#endif } private func applyPendingForkValidations() { @@ -279,13 +314,16 @@ final class SharedLiveAgentIndex { changePending = true return } + let reloadInterval = Self.hookEventReloadInterval( + liveAgentCount: liveAgentProcessFingerprint.count + ) let elapsed = loadedAt.map { dateProvider().timeIntervalSince($0) } ?? .infinity - if elapsed >= Self.minEventReloadInterval { + if elapsed >= reloadInterval { startReload() } else if deferredReloadTimer == nil { // DispatchSourceTimer coalesces hook-store event bursts without Task.sleep in runtime code. let timer = DispatchSource.makeTimerSource(queue: watchQueue) - timer.schedule(deadline: .now() + (Self.minEventReloadInterval - elapsed)) + timer.schedule(deadline: .now() + (reloadInterval - elapsed)) timer.setEventHandler { [weak self] in Task { @MainActor in guard let self else { return } diff --git a/Sources/SharedLiveAgentIndexLoader.swift b/Sources/SharedLiveAgentIndexLoader.swift index ffb9b0cf4ee0..7fdb32fb37ec 100644 --- a/Sources/SharedLiveAgentIndexLoader.swift +++ b/Sources/SharedLiveAgentIndexLoader.swift @@ -15,6 +15,7 @@ struct SharedLiveAgentIndexLoader { private let processSnapshotProvider: () -> CmuxTopProcessSnapshot private let capturedAtProvider: () -> TimeInterval private let processArgumentsProvider: (Int) -> CmuxTopProcessArguments? + private let injectedProcessArgumentsProvider: ((Int) -> CmuxTopProcessArguments?)? private let processIdentityProvider: (Int) -> AgentPIDProcessIdentity? private let cachedAgentProcessValidator: CachedAgentProcessIdentityValidator @@ -22,15 +23,11 @@ struct SharedLiveAgentIndexLoader { homeDirectory: String = NSHomeDirectory(), fileManager: FileManager = .default, registry: CmuxVaultAgentRegistry? = nil, - processSnapshotProvider: @escaping () -> CmuxTopProcessSnapshot = { - CmuxTopProcessSnapshot.capture(includeProcessDetails: true) - }, + processSnapshotProvider: @escaping () -> CmuxTopProcessSnapshot, capturedAtProvider: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, - processArgumentsProvider: @escaping (Int) -> CmuxTopProcessArguments? = { - CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) - }, + processArgumentsProvider: ((Int) -> CmuxTopProcessArguments?)? = nil, processIdentityProvider: @escaping (Int) -> AgentPIDProcessIdentity? = { guard $0 > 0, $0 <= Int(Int32.max) else { return nil } return AgentPIDProcessIdentity(pid: pid_t($0)) @@ -42,7 +39,10 @@ struct SharedLiveAgentIndexLoader { self.registry = registry self.processSnapshotProvider = processSnapshotProvider self.capturedAtProvider = capturedAtProvider - self.processArgumentsProvider = processArgumentsProvider + self.injectedProcessArgumentsProvider = processArgumentsProvider + self.processArgumentsProvider = processArgumentsProvider ?? { + CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) + } self.processIdentityProvider = processIdentityProvider self.cachedAgentProcessValidator = cachedAgentProcessValidator } @@ -55,12 +55,18 @@ struct SharedLiveAgentIndexLoader { let resolvedRegistry = registry ?? CmuxVaultAgentRegistry.load(homeDirectory: homeDirectory, fileManager: fileManager) let processSnapshot = processSnapshotProvider() +#if DEBUG + let loadMetricsToken = ProcessPerformanceMetrics.shared.operationStarted( + .restorableLoad, + inputCount: processSnapshot.processesByPID.count + ) +#endif let detectedSnapshots = RestorableAgentSessionIndex.processDetectedSnapshots( registry: resolvedRegistry, fileManager: fileManager, processSnapshot: processSnapshot, capturedAt: capturedAtProvider(), - processArgumentsProvider: processArgumentsProvider + processArgumentsProvider: injectedProcessArgumentsProvider ) let index = RestorableAgentSessionIndex.load( homeDirectory: homeDirectory, @@ -70,6 +76,12 @@ struct SharedLiveAgentIndexLoader { processArgumentsProvider: processArgumentsProvider, processIdentityProvider: processIdentityProvider ) +#if DEBUG + ProcessPerformanceMetrics.shared.operationCompleted( + loadMetricsToken, + outputCount: index.forkValidationEntries().count + ) +#endif return ( index: index, liveAgentProcessFingerprint: index.liveAgentProcessFingerprint(), diff --git a/Sources/SidebarWorkspaceStatusPopover.swift b/Sources/SidebarWorkspaceStatusPopover.swift index 6ae080c33968..c4273e626182 100644 --- a/Sources/SidebarWorkspaceStatusPopover.swift +++ b/Sources/SidebarWorkspaceStatusPopover.swift @@ -20,7 +20,6 @@ struct WorkspaceTodoStatusLane: Equatable, Identifiable { var id: String { isNone ? "none" : (status?.rawValue ?? "auto") } } - extension WorkspaceTodoStatusLane { /// The ordered lane list: Auto first, then the five status lanes, then None. /// @@ -265,4 +264,3 @@ struct SidebarWorkspaceStatusPopover: View { return true } } - diff --git a/Sources/SurfaceResumeBindingIndex.swift b/Sources/SurfaceResumeBindingIndex.swift index cd82fda650d4..85262d3eba61 100644 --- a/Sources/SurfaceResumeBindingIndex.swift +++ b/Sources/SurfaceResumeBindingIndex.swift @@ -24,18 +24,22 @@ nonisolated struct SurfaceResumeBindingIndex: Sendable { bindingsByPanel[PanelKey(workspaceId: workspaceId, panelId: panelId)] ?? bindingsByPanelId[panelId] } - static func loadProcessDetectedBindingsSynchronously( - fileManager: FileManager = .default - ) -> SurfaceResumeBindingIndex { - let detectedBindings = processDetectedTmuxBindings(fileManager: fileManager) - return SurfaceResumeBindingIndex(bindingsByPanel: detectedBindings.mapValues(\.binding)) - } - static func loadIncludingProcessDetectedBindings( - fileManager: FileManager = .default + fileManager: FileManager = .default, + snapshotStore: CmuxTopProcessSnapshotStore = .shared ) async -> SurfaceResumeBindingIndex { - await Task.detached(priority: .utility) { - loadProcessDetectedBindingsSynchronously(fileManager: fileManager) + let processSnapshot = await snapshotStore.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 3, + consumer: .processDetectedResume + ) + return await Task.detached(priority: .utility) { + let detectedBindings = processDetectedTmuxBindings( + fileManager: fileManager, + processSnapshot: processSnapshot, + capturedAt: processSnapshot.sampledAt.timeIntervalSince1970 + ) + return SurfaceResumeBindingIndex(bindingsByPanel: detectedBindings.mapValues(\.binding)) }.value } } diff --git a/Sources/TabManager+BackgroundWorkspaceMountPolicy.swift b/Sources/TabManager+BackgroundWorkspaceMountPolicy.swift deleted file mode 100644 index b0f81e5d9f43..000000000000 --- a/Sources/TabManager+BackgroundWorkspaceMountPolicy.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -extension TabManager { - /// Upper bound on how many background-prime workspaces may be force-mounted - /// into the single main-window SwiftUI GraphHost at once. - /// - /// `reconcileMountedWorkspaceIds` raises the mount cap by - /// `mountedBackgroundWorkspaceLoadIds.count`, so an unbounded set here would - /// let a burst of eagerly-loaded (unfocused) workspaces mount every one of - /// their pane subtrees simultaneously — making `GraphHost.flushTransactions()` - /// O(number-of-hosted-panes) per runloop tick and pinning the main thread at - /// 100%+ CPU (issue #7136). Background priming is processed serially (one - /// awaited workspace at a time via a single-flight `.task(id:)`), so a small - /// constant is sufficient; overflow workspaces still start their terminal - /// surface through the headless startup-window path, so priming is deferred, - /// never dropped. - static let maxConcurrentBackgroundWorkspaceMounts = 2 - - /// Returns whether `workspaceId` may occupy a background-prime mount slot. - func shouldRetainBackgroundWorkspaceMount(for workspaceId: UUID) -> Bool { - !mountedBackgroundWorkspaceLoadIds.contains(workspaceId) - && mountedBackgroundWorkspaceLoadIds.count < Self.maxConcurrentBackgroundWorkspaceMounts - } -} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index e891a721ece7..8f2517dace01 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -227,7 +227,6 @@ class TabManager: ObservableObject { private var isRestoringSessionSnapshot: Bool = false @Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] - @Published private(set) var mountedBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. @@ -448,6 +447,10 @@ class TabManager: ObservableObject { // default) on purpose: the cap is per process, not per window, matching // the legacy shared limiter; tests inject their own instance. private static let sharedWorkspaceGitProbeLimiter = WorkspaceGitMetadataProbeLimiter(limit: 2) + // Default windows share one explicit CmuxGit coordination scope and one + // fallback clock. Tests can still inject isolated services/coordinators. + private static let sharedGitTrackedChangesSnapshotScope = GitTrackedChangesSnapshotScope() + private static let sharedWorkspaceGitFallbackCoordinator = WorkspaceGitFallbackCoordinator() // The sidebar git/PR subsystem (extracted to CmuxSidebarGit). TabManager // is the per-window composition point: it constructs the concrete @@ -463,14 +466,18 @@ class TabManager: ObservableObject { initialTerminalInput: String? = nil, autoWelcomeIfNeeded: Bool = true, commandRunner: any CommandRunning = CommandRunner(), - gitMetadataService: GitMetadataService = GitMetadataService(), + gitMetadataService: GitMetadataService? = nil, workspaceGitMetadataReader: (any WorkspaceGitMetadataReading)? = nil, gitPollClock: any GitPollClock = SystemGitPollClock(), gitProbeLimiter: WorkspaceGitMetadataProbeLimiter? = nil, + gitFallbackCoordinator: WorkspaceGitFallbackCoordinator? = nil, panelTitleUpdateCoalescer: NotificationBurstCoalescer? = nil, settings: any SettingsWriting = UserDefaultsSettingsClient(defaults: .standard), closeTabWarningDefaults: UserDefaults = .standard ) { + let gitMetadataService = gitMetadataService ?? GitMetadataService( + trackedChangesSnapshotScope: Self.sharedGitTrackedChangesSnapshotScope + ) self.settings = settings self.panelTitleUpdateCoalescer = panelTitleUpdateCoalescer ?? NotificationBurstCoalescer() self.closeTabWarningDefaults = closeTabWarningDefaults @@ -509,6 +516,8 @@ class TabManager: ObservableObject { pullRequestProbing: pullRequestPollService, probeLimiter: gitProbeLimiter ?? Self.sharedWorkspaceGitProbeLimiter, clock: gitPollClock, + fallbackCoordinator: gitFallbackCoordinator + ?? Self.sharedWorkspaceGitFallbackCoordinator, debugLog: sidebarGitDebugLog ) // Wire the host seam before the first workspace is added so the @@ -1246,21 +1255,6 @@ class TabManager: ObservableObject { var updated = pendingBackgroundWorkspaceLoadIds updated.remove(workspaceId) pendingBackgroundWorkspaceLoadIds = updated - releaseBackgroundWorkspaceMount(for: workspaceId) - } - - func retainBackgroundWorkspaceMount(for workspaceId: UUID) { - guard shouldRetainBackgroundWorkspaceMount(for: workspaceId) else { return } - var updated = mountedBackgroundWorkspaceLoadIds - updated.insert(workspaceId) - mountedBackgroundWorkspaceLoadIds = updated - } - - func releaseBackgroundWorkspaceMount(for workspaceId: UUID) { - guard mountedBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } - var updated = mountedBackgroundWorkspaceLoadIds - updated.remove(workspaceId) - mountedBackgroundWorkspaceLoadIds = updated } func retainDebugWorkspaceLoads(for workspaceIds: Set) { @@ -1284,10 +1278,6 @@ class TabManager: ObservableObject { if pruned != pendingBackgroundWorkspaceLoadIds { pendingBackgroundWorkspaceLoadIds = pruned } - let mounted = mountedBackgroundWorkspaceLoadIds.intersection(existingIds) - if mounted != mountedBackgroundWorkspaceLoadIds { - mountedBackgroundWorkspaceLoadIds = mounted - } let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds) if retained != debugPinnedWorkspaceLoadIds { debugPinnedWorkspaceLoadIds = retained diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift new file mode 100644 index 000000000000..651d1fbda4bb --- /dev/null +++ b/Sources/TerminalController+Capabilities.swift @@ -0,0 +1,258 @@ +import Foundation + +extension TerminalController { + nonisolated func v2Capabilities() -> [String: Any] { + var methods: [String] = [ + "system.ping", + "system.capabilities", + "system.identify", + "system.tree", + "sidebar.custom.open", + "system.top", "system.memory", "performance.metrics.exercise_process", + "performance.metrics.exercise_git_pr", + "mobile.host.status", + "mobile.attach_ticket.create", + "mobile.terminal.set_font", + "mobile.workspace.list", + "mobile.terminal.create", + "mobile.terminal.input", + "mobile.terminal.paste", + "mobile.terminal.replay", + "mobile.terminal.viewport", "mobile.events.subscribe", "mobile.events.unsubscribe", + "terminal.create", + "terminal.input", + "terminal.paste", + "terminal.replay", + "terminal.viewport", + "auth.login", + "auth.status", + "auth.sign_in_url", + "auth.begin_sign_in", + "auth.sign_out", + "vm.list", + "vm.create", + "vm.destroy", + "vm.exec", + "vm.attach_info", + "vm.ssh_info", + "aiAccounts.list", + "aiAccounts.upload", + "aiAccounts.remove", + "window.list", + "window.current", + "window.focus", + "window.create", + "window.close", + "window.displays", + "window.display", + "workspace.list", + "workspace.create", + "workspace.cloud_vm_open", + "workspace.cloud_vm_terminal_ready", + "workspace.env", + "workspace.select", + "workspace.current", + "workspace.close", + "workspace.move_to_window", + "workspace.reorder", + "workspace.reorder_many", + "workspace.prompt_submit", + "workspace.rename", + "workspace.set_auto_title", + "workspace.group.list", + "workspace.group.create", + "workspace.group.ungroup", + "workspace.group.delete", + "workspace.group.rename", + "workspace.group.collapse", + "workspace.group.expand", + "workspace.group.pin", + "workspace.group.unpin", + "workspace.group.add", + "workspace.group.remove", + "workspace.group.set_anchor", + "workspace.group.new_workspace", + "workspace.group.set_color", + "workspace.group.set_icon", + "workspace.group.move", + "workspace.group.focus", + "workspace.action", + "extension.sidebar.snapshot", + "workspace.next", + "workspace.previous", + "workspace.last", + "workspace.equalize_splits", + "workspace.remote.configure", + "workspace.remote.foreground_auth_ready", + "workspace.remote.reconnect", + "workspace.remote.disconnect", + "workspace.remote.status", + "workspace.remote.pty_sessions", "workspace.remote.pty_close", "workspace.remote.pty_detach", + "workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_attach_end", + "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "remote.tmux.pane_grids", + "session.restore_previous", + "settings.open", + "feedback.open", + "feedback.submit", + "feed.push", + "feed.permission.reply", + "feed.question.reply", + "feed.exit_plan.reply", + "feed.jump", + "feed.list", + "surface.list", + "surface.current", + "surface.focus", + "surface.split", + "surface.respawn", + "surface.create", + "surface.close", + "surface.drag_to_split", + "surface.split_off", + "surface.move", + "surface.reorder", + "surface.action", + "tab.action", + "surface.refresh", + "surface.health", + "surface.resume.set", + "surface.resume.get", + "surface.resume.clear", + "debug.terminals", + "surface.send_text", + "surface.send_key", + "surface.report_tty", + "surface.report_pwd", + "surface.report_shell_state", + "surface.ports_kick", + "surface.read_text", + "surface.clear_history", + "surface.trigger_flash", + "pane.list", + "pane.focus", + "pane.surfaces", + "pane.create", + "pane.resize", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", + "notification.create", + "notification.create_for_caller", "agent.resolve_delivery_target", + "notification.create_for_surface", + "notification.create_for_target", + "notification.list", + "notification.clear", + "notification.dismiss", + "notification.mark_read", + "notification.open", + "notification.jump_to_unread", + "app.focus_override.set", + "app.simulate_active", + "file.open", + "markdown.open", + "browser.open_split", + "browser.navigate", + "browser.back", + "browser.forward", + "browser.reload", + "browser.react_grab.toggle", + "browser.devtools.toggle", + "browser.console.show", + "browser.focus_mode.set", + "browser.zoom.set", + "browser.history.clear", + "browser.url.get", + "browser.snapshot", + "browser.eval", + "browser.wait", + "browser.click", + "browser.dblclick", + "browser.hover", + "browser.focus", + "browser.type", + "browser.fill", + "browser.press", + "browser.keydown", + "browser.keyup", + "browser.check", + "browser.uncheck", + "browser.select", + "browser.scroll", + "browser.scroll_into_view", + "browser.screenshot", + "browser.get.text", + "browser.get.html", + "browser.get.value", + "browser.get.attr", + "browser.get.title", + "browser.get.count", + "browser.get.box", + "browser.get.styles", + "browser.is.visible", + "browser.is.enabled", + "browser.is.checked", + "browser.focus_webview", + "browser.is_webview_focused", + "browser.find.role", + "browser.find.text", + "browser.find.label", + "browser.find.placeholder", + "browser.find.alt", + "browser.find.title", + "browser.find.testid", + "browser.find.first", + "browser.find.last", + "browser.find.nth", + "browser.frame.select", + "browser.frame.main", + "browser.dialog.accept", + "browser.dialog.dismiss", + "browser.download.wait", + "browser.cookies.get", + "browser.cookies.set", + "browser.cookies.clear", + "browser.storage.get", + "browser.storage.set", + "browser.storage.clear", + "browser.tab.new", + "browser.tab.list", + "browser.tab.switch", + "browser.tab.close", + "browser.console.list", + "browser.console.clear", + "browser.errors.list", + "browser.highlight", + "browser.state.save", + "browser.state.load", + "browser.addinitscript", + "browser.addscript", + "browser.addstyle", + "browser.viewport.set", + "browser.geolocation.set", + "browser.offline.set", + "browser.trace.start", + "browser.trace.stop", + "browser.network.route", + "browser.network.unroute", + "browser.network.requests", + "browser.screencast.start", + "browser.screencast.stop", + "browser.input_mouse", + "browser.input_keyboard", + "browser.input_touch", + "performance.metrics.read", "performance.metrics.reset", "performance.metrics.stop", + ] +#if DEBUG + methods.append(contentsOf: Self.v2DebugMethodNames) +#endif + + return [ + "protocol": "cmux-socket", + "version": 2, + "socket_path": socketServer.currentSocketPath, + "access_mode": socketServer.accessMode.rawValue, + "methods": methods.sorted() + ] + } +} diff --git a/Sources/TerminalController+ControlDebugContext.swift b/Sources/TerminalController+ControlDebugContext.swift index 8d38667ab5b6..c6731c101481 100644 --- a/Sources/TerminalController+ControlDebugContext.swift +++ b/Sources/TerminalController+ControlDebugContext.swift @@ -66,6 +66,17 @@ extension TerminalController: ControlDebugContext { return JSONValue(foundationObject: payload) } + func controlDebugReadProcessPerformanceMetrics() -> JSONValue? { + JSONValue( + foundationObject: ProcessPerformanceMetrics.shared.snapshot().foundationObject + ) + } + + func controlDebugResetProcessPerformanceMetrics() -> JSONValue? { + ProcessPerformanceMetrics.shared.reset() + return controlDebugReadProcessPerformanceMetrics() + } + // MARK: - v1-shared forwards (bodies stay in TerminalController.swift) func controlDebugSetShortcut(arguments: String) -> String { setShortcut(arguments) } diff --git a/Sources/TerminalController+ControlPerformanceContext.swift b/Sources/TerminalController+ControlPerformanceContext.swift new file mode 100644 index 000000000000..23320f643a4a --- /dev/null +++ b/Sources/TerminalController+ControlPerformanceContext.swift @@ -0,0 +1,200 @@ +import CmuxControlSocket +import CmuxGit +import CmuxSidebarGit +import Foundation + +extension TerminalController: ControlPerformanceContext { + nonisolated func v2PerformanceMetricsExerciseGitPR( + params: [String: Any] + ) async -> V2CallResult { + guard Set(params.keys) == ["concurrent_requests", "exercise_nonce"], + let nonce = params["exercise_nonce"] as? String, + nonce.range(of: #"^[0-9a-f]{64}$"#, options: .regularExpression) != nil, + let concurrentRequests = v2Int(params, "concurrent_requests"), + (2...8).contains(concurrentRequests) else { + return .err( + code: "invalid_params", + message: "exercise_nonce must be 64 lowercase hex and concurrent_requests must be 2...8", + data: nil + ) + } + let sidebarMetricsEnabled = await MainActor.run { + SidebarGitMetadataService.runtimeMetricsSnapshot().enabled + } + guard GitMetadataService.runtimeMetricsSnapshot().enabled, + sidebarMetricsEnabled else { + return .err( + code: "metrics_disabled", + message: "Reset performance metrics before exercising Git and PR owners", + data: nil + ) + } + + do { + let git = try await GitOwnerPerformanceExercise().run( + requestCount: concurrentRequests + ) + let sidebarGit = try await SidebarGitOwnerPerformanceExercise().run( + requestCount: concurrentRequests + ) + guard git.completedSnapshotCount == concurrentRequests, + git.allSnapshotsMatched, + git.allWaitersRegistered, + sidebarGit.singleFlightApplyCount == 1, + sidebarGit.staleApplyCountBeforeFollowUp == 0, + sidebarGit.staleFinalApplyCount == 1, + let staleFinalBranch = sidebarGit.staleFinalBranch, + staleFinalBranch == "feature/stale-b" else { + return .err( + code: "exercise_failed", + message: "Git and PR owner exercise did not complete its production ownership paths", + data: nil + ) + } + return .ok([ + "schema_version": 1, + "exercise_nonce": nonce, + "request_count": concurrentRequests, + "isolation": "temporary_git_repository_and_in_memory_pr_host", + "git": [ + "completed_snapshot_count": git.completedSnapshotCount, + "all_snapshots_matched": git.allSnapshotsMatched, + "all_waiters_registered": git.allWaitersRegistered, + ], + "sidebar_git": [ + "single_flight_apply_count": sidebarGit.singleFlightApplyCount, + "stale_apply_count_before_follow_up": sidebarGit.staleApplyCountBeforeFollowUp, + "stale_final_apply_count": sidebarGit.staleFinalApplyCount, + "stale_final_branch": staleFinalBranch, + ], + ]) + } catch { + return .err( + code: "exercise_failed", + message: "Git and PR owner exercise failed", + data: nil + ) + } + } + + nonisolated func v2PerformanceMetricsExerciseProcess( + params: [String: Any] + ) async -> V2CallResult { + guard Set(params.keys) == ["concurrent_requests", "exercise_nonce"], + let nonce = params["exercise_nonce"] as? String, + nonce.range(of: #"^[0-9a-f]{64}$"#, options: .regularExpression) != nil, + let concurrentRequests = v2Int(params, "concurrent_requests"), + (2...8).contains(concurrentRequests) else { + return .err( + code: "invalid_params", + message: "exercise_nonce must be 64 lowercase hex and concurrent_requests must be 2...8", + data: nil + ) + } + let startingMetrics = ProcessPerformanceMetrics.shared.snapshot() + guard startingMetrics.enabled, startingMetrics.measurementEpoch > 0 else { + return .err( + code: "metrics_disabled", + message: "Reset performance metrics before exercising process owners", + data: nil + ) + } + + guard let process = await CmuxTopProcessSnapshotStore.shared.performanceMetricsExercise( + requestCount: concurrentRequests + ), let listenerProof = await PortScanner.shared.performanceMetricsExercise( + pids: [Int(getpid())] + ) else { + return .err( + code: "exercise_failed", + message: "Process owner exercise did not complete its production capture path", + data: nil + ) + } + let completedMetrics = ProcessPerformanceMetrics.shared.snapshot() + guard process.processCount > 0, + process.measurementEpoch == startingMetrics.measurementEpoch, + completedMetrics.enabled, + completedMetrics.measurementEpoch == process.measurementEpoch, + let generationMetrics = completedMetrics.generations[process.generation], + generationMetrics.started == 1, + generationMetrics.completed == 1, + generationMetrics.processCount == process.processCount else { + return .err( + code: "exercise_failed", + message: "Process owner exercise did not match its measurement epoch and generation metrics", + data: nil + ) + } + return .ok([ + "schema_version": 2, + "exercise_nonce": nonce, + "measurement_epoch": NSNumber(value: process.measurementEpoch), + "capture_generation": NSNumber(value: process.generation), + "request_count": concurrentRequests, + "process_count": process.processCount, + "process_shared_snapshot": process.sharedSnapshotIdentity, + "primary_consumer": ProcessSnapshotConsumer.performanceExercisePrimary.rawValue, + "secondary_consumer": ProcessSnapshotConsumer.performanceExerciseSecondary.rawValue, + "process_backend": process.proof.backend.rawValue, + "process_launches": max(0, process.proof.processLaunchCount), + "listener_backend": listenerProof.proof.backend.rawValue, + "listener_process_launches": max(0, listenerProof.proof.processLaunchCount), + "listener_shared_result": listenerProof.sharedResult, + ]) + } + + func controlPerformanceMetricsRead() -> JSONValue? { + let git = GitMetadataService.runtimeMetricsSnapshot() + let sidebarGit = SidebarGitMetadataService.runtimeMetricsSnapshot() + return JSONValue( + foundationObject: [ + "schema_version": 1, + "process": ProcessPerformanceMetrics.shared.snapshot().foundationObject, + "mobile_workspace": MobileWorkspaceObserverMetrics.shared.snapshot().foundationObject, + "git": [ + "schema_version": NSNumber(value: git.schemaVersion), + "enabled": git.enabled, + "raw_tracked_status_scan_count": NSNumber(value: git.rawTrackedStatusScanCount), + "tracked_status_cache_hit_count": NSNumber(value: git.trackedStatusCacheHitCount), + "tracked_status_in_flight_join_count": NSNumber(value: git.trackedStatusInFlightJoinCount), + "tracked_status_request_count": NSNumber(value: git.trackedStatusRequestCount), + ], + "sidebar_git": [ + "schema_version": NSNumber(value: sidebarGit.schemaVersion), + "enabled": sidebarGit.enabled, + "snapshot_batch_apply_count": NSNumber(value: sidebarGit.snapshotBatchApplyCount), + "material_change_count": NSNumber(value: sidebarGit.materialChangeCount), + "pull_request_seed_count": NSNumber(value: sidebarGit.pullRequestSeedCount), + "pull_request_traversal_count": NSNumber(value: sidebarGit.pullRequestTraversalCount), + "stale_apply_count": NSNumber(value: sidebarGit.staleApplyCount), + "pull_request_refresh_request_count": NSNumber(value: sidebarGit.pullRequestRefreshRequestCount), + "task_started": NSNumber(value: sidebarGit.pullRequestTaskStartedCount), + "task_joined": NSNumber(value: sidebarGit.pullRequestTaskJoinedCount), + "repo_fetch": NSNumber(value: sidebarGit.pullRequestRepoFetchCount), + "stale_completion_rejected_off_main": NSNumber(value: sidebarGit.pullRequestStaleCompletionRejectedOffMainCount), + "main_actor_apply_entered": NSNumber(value: sidebarGit.pullRequestMainActorApplyEnteredCount), + "follow_up_started": NSNumber(value: sidebarGit.pullRequestFollowUpStartedCount), + "git_stale_completion_rejected_off_main": NSNumber(value: sidebarGit.gitStaleCompletionRejectedOffMainCount), + "git_main_actor_apply_entered": NSNumber(value: sidebarGit.gitMainActorApplyEnteredCount), + ], + ] + ) + } + + func controlPerformanceMetricsReset() -> JSONValue? { + ProcessPerformanceMetrics.shared.reset(enable: true) + MobileWorkspaceObserverMetrics.shared.reset(enable: true) + GitMetadataService.resetRuntimeMetrics(enable: true) + SidebarGitMetadataService.resetRuntimeMetrics(enable: true) + return controlPerformanceMetricsRead() + } + + func controlPerformanceMetricsStop() -> JSONValue? { + ProcessPerformanceMetrics.shared.disable() + MobileWorkspaceObserverMetrics.shared.disable() + GitMetadataService.disableRuntimeMetrics() + SidebarGitMetadataService.disableRuntimeMetrics() + return controlPerformanceMetricsRead() + } +} diff --git a/Sources/TerminalController+DebugMethodNames.swift b/Sources/TerminalController+DebugMethodNames.swift index 969e51f2f6b4..6fc8564d9217 100644 --- a/Sources/TerminalController+DebugMethodNames.swift +++ b/Sources/TerminalController+DebugMethodNames.swift @@ -42,6 +42,8 @@ extension TerminalController { "debug.panel_snapshot.reset", "debug.session_snapshot_benchmark", "debug.session_snapshot_seed_scrollback", + "debug.process_metrics.read", + "debug.process_metrics.reset", "debug.window.screenshot", "debug.terminal.simulate_file_drop", "debug.sidebar.simulate_drag", diff --git a/Sources/TerminalController+SocketClientCapability.swift b/Sources/TerminalController+SocketClientCapability.swift index 152ea2767c07..838b95de25c3 100644 --- a/Sources/TerminalController+SocketClientCapability.swift +++ b/Sources/TerminalController+SocketClientCapability.swift @@ -33,25 +33,23 @@ extension TerminalController { ] } - nonisolated func socketClientInitialReadLimits( - peerProcessID: pid_t? - ) -> ControlClientLineReadLimits? { - guard socketServer.accessMode == .cmuxOnly, - !(peerProcessID.map(isDescendant) ?? false) else { - return nil - } + /// Bounds every cmux-only peer until its first command can choose capability + /// or ancestry authorization without a speculative process-tree walk. + nonisolated func socketClientInitialReadLimits() -> ControlClientLineReadLimits? { + guard socketServer.accessMode == .cmuxOnly else { return nil } return Self.socketClientPreauthorizationLimits } nonisolated func authorizedSocketCommand( _ command: String, peerProcessID: pid_t?, - peerHasSameUID: Bool + peerHasSameUID: Bool, + authorization: inout SocketClientAuthorization ) -> String? { guard socketServer.accessMode == .cmuxOnly else { return SocketClientCapabilityCommand(command)?.command ?? command } - return SocketClientAuthorization().authorizedCommand( + return authorization.authorizedCommand( command, peerProcessID: peerProcessID, peerHasSameUID: peerHasSameUID, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 13efbd01ab91..aba27f320e85 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1356,10 +1356,10 @@ class TerminalController { return v2Ok(id: request.id, result: ["pong": true]) case "system.capabilities": return v2Ok(id: request.id, result: v2Capabilities()) - case "system.top": - return v2Result(id: request.id, v2SystemTop(params: request.params)) - case "system.memory": - return v2Result(id: request.id, v2SystemMemory(params: request.params)) + case "system.top": return v2AsyncResultCall(id: request.id, timeoutSeconds: 30) { await self.v2SystemTop(params: request.params) } + case "system.memory": return v2AsyncResultCall(id: request.id, timeoutSeconds: 30) { await self.v2SystemMemory(params: request.params) } + case "performance.metrics.exercise_process": return v2AsyncResultCall(id: request.id, timeoutSeconds: 120) { await self.v2PerformanceMetricsExerciseProcess(params: request.params) } + case "performance.metrics.exercise_git_pr": return v2AsyncResultCall(id: request.id, timeoutSeconds: 120) { await self.v2PerformanceMetricsExerciseGitPR(params: request.params) } case "surface.read_text": return v2Result(id: request.id, v2SurfaceReadText(params: request.params)) case "workspace.env": @@ -1382,8 +1382,10 @@ class TerminalController { return v2RemoteTmuxDetach(id: request.id, params: request.params) case "remote.tmux.state": return v2RemoteTmuxState(id: request.id, params: request.params) - case "remote.tmux.mirror": return v2RemoteTmuxMirror(id: request.id, params: request.params) - case "remote.tmux.window": return v2RemoteTmuxWindow(id: request.id, params: request.params) + case "remote.tmux.mirror": + return v2RemoteTmuxMirror(id: request.id, params: request.params) + case "remote.tmux.window": + return v2RemoteTmuxWindow(id: request.id, params: request.params) case "remote.tmux.pane_grids": return v2RemoteTmuxPaneGrids(id: request.id, params: request.params) #if DEBUG case "remote.tmux.test_exec": return v2RemoteTmuxTestExec(id: request.id, params: request.params) @@ -1433,26 +1435,40 @@ class TerminalController { } private nonisolated func spawnClientHandler(socket clientSocket: Int32, peerPid: pid_t?) async { - let initialReadLimits = socketClientInitialReadLimits(peerProcessID: peerPid) + var initialReadLimits = socketClientInitialReadLimits() + var clientAuthorization = SocketClientAuthorization() + let preauthorizationLimiter = socketClientPreauthorizationLimiter let claimedPreauthorizationSlot = if initialReadLimits != nil { - await socketClientPreauthorizationLimiter.claim() + await preauthorizationLimiter.claim() } else { false } - guard initialReadLimits == nil || claimedPreauthorizationSlot else { - close(clientSocket) - return + if initialReadLimits != nil, !claimedPreauthorizationSlot { + guard clientAuthorization.cacheAncestryAuthorization( + peerProcessID: peerPid, + isDescendant: { isDescendant($0) } + ) else { + close(clientSocket) + return + } + initialReadLimits = nil } + let acceptedReadLimits = initialReadLimits + let acceptedAuthorization = clientAuthorization Thread.detachNewThread { [weak self] in guard let self else { close(clientSocket) + if claimedPreauthorizationSlot { + Task { await preauthorizationLimiter.release() } + } return } self.handleClient( clientSocket, peerPid: peerPid, - initialReadLimits: initialReadLimits, - holdsPreauthorizationSlot: claimedPreauthorizationSlot + initialReadLimits: acceptedReadLimits, + holdsPreauthorizationSlot: claimedPreauthorizationSlot, + authorization: acceptedAuthorization ) } } @@ -1461,7 +1477,8 @@ class TerminalController { _ socket: Int32, peerPid: pid_t? = nil, initialReadLimits: ControlClientLineReadLimits? = nil, - holdsPreauthorizationSlot initialSlotHeld: Bool = false + holdsPreauthorizationSlot initialSlotHeld: Bool = false, + authorization initialAuthorization: SocketClientAuthorization = .init() ) { defer { close(socket) } let pid = peerPid ?? transport.peerProcessID(of: socket) @@ -1474,6 +1491,7 @@ class TerminalController { } } var authenticated = false + var clientAuthorization = initialAuthorization let lineReader = ControlClientLineReader(socket: socket, initialLimits: initialReadLimits) while let line = lineReader.nextLine(shouldContinueReading: { socketServer.isRunning }) { @@ -1482,7 +1500,8 @@ class TerminalController { guard let trimmed = authorizedSocketCommand( receivedCommand, peerProcessID: pid, - peerHasSameUID: peerHasSameUID + peerHasSameUID: peerHasSameUID, + authorization: &clientAuthorization ) else { _ = writeSocketResponse( pid == nil ? "ERROR: Unable to verify client process" @@ -2236,7 +2255,8 @@ class TerminalController { // ControlCommandCoordinator (create_for_caller keeps its app-side resolver). case "notification.create_for_caller": return v2Result(id: id, self.v2NotificationCreateForCaller(params: params)) - case "agent.resolve_delivery_target": return v2Result(id: id, self.v2AgentResolveDeliveryTarget(params: params)) + case "agent.resolve_delivery_target": + return v2Result(id: id, self.v2AgentResolveDeliveryTarget(params: params)) // App focus (app.focus_override.set/app.simulate_active) handled by ControlCommandCoordinator. @@ -2329,260 +2349,6 @@ class TerminalController { } } - private nonisolated func v2Capabilities() -> [String: Any] { - var methods: [String] = [ - "system.ping", - "system.capabilities", - "system.identify", - "system.tree", - "sidebar.custom.open", - "system.top", - "system.memory", - "mobile.host.status", - "mobile.attach_ticket.create", - "mobile.terminal.set_font", - "mobile.workspace.list", - "mobile.terminal.create", - "mobile.terminal.input", - "mobile.terminal.paste", - "mobile.terminal.replay", - "mobile.terminal.viewport", "mobile.events.subscribe", "mobile.events.unsubscribe", - "terminal.create", - "terminal.input", - "terminal.paste", - "terminal.replay", - "terminal.viewport", - "auth.login", - "auth.status", - "auth.sign_in_url", - "auth.begin_sign_in", - "auth.sign_out", - "vm.list", - "vm.create", - "vm.destroy", - "vm.exec", - "vm.attach_info", - "vm.ssh_info", - "aiAccounts.list", - "aiAccounts.upload", - "aiAccounts.remove", - "window.list", - "window.current", - "window.focus", - "window.create", - "window.close", - "window.displays", - "window.display", - "workspace.list", - "workspace.create", - "workspace.cloud_vm_open", - "workspace.cloud_vm_terminal_ready", - "workspace.env", - "workspace.select", - "workspace.current", - "workspace.close", - "workspace.move_to_window", - "workspace.reorder", - "workspace.reorder_many", - "workspace.prompt_submit", - "workspace.rename", - "workspace.set_auto_title", - "workspace.group.list", - "workspace.group.create", - "workspace.group.ungroup", - "workspace.group.delete", - "workspace.group.rename", - "workspace.group.collapse", - "workspace.group.expand", - "workspace.group.pin", - "workspace.group.unpin", - "workspace.group.add", - "workspace.group.remove", - "workspace.group.set_anchor", - "workspace.group.new_workspace", - "workspace.group.set_color", - "workspace.group.set_icon", - "workspace.group.move", - "workspace.group.focus", - "workspace.action", - "extension.sidebar.snapshot", - "workspace.next", - "workspace.previous", - "workspace.last", - "workspace.equalize_splits", - "workspace.remote.configure", - "workspace.remote.foreground_auth_ready", - "workspace.remote.reconnect", - "workspace.remote.disconnect", - "workspace.remote.status", - "workspace.remote.pty_sessions", "workspace.remote.pty_close", "workspace.remote.pty_detach", - "workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_attach_end", - "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "remote.tmux.pane_grids", - "session.restore_previous", - "settings.open", - "feedback.open", - "feedback.submit", - "feed.push", - "feed.permission.reply", - "feed.question.reply", - "feed.exit_plan.reply", - "feed.jump", - "feed.list", - "surface.list", - "surface.current", - "surface.focus", - "surface.split", - "surface.respawn", - "surface.create", - "surface.close", - "surface.drag_to_split", - "surface.split_off", - "surface.move", - "surface.reorder", - "surface.action", - "tab.action", - "surface.refresh", - "surface.health", - "surface.resume.set", - "surface.resume.get", - "surface.resume.clear", - "debug.terminals", - "surface.send_text", - "surface.send_key", - "surface.report_tty", - "surface.report_pwd", - "surface.report_shell_state", - "surface.ports_kick", - "surface.read_text", - "surface.clear_history", - "surface.trigger_flash", - "pane.list", - "pane.focus", - "pane.surfaces", - "pane.create", - "pane.resize", - "pane.swap", - "pane.break", - "pane.join", - "pane.last", - "notification.create", - "notification.create_for_caller", "agent.resolve_delivery_target", - "notification.create_for_surface", - "notification.create_for_target", - "notification.list", - "notification.clear", - "notification.dismiss", - "notification.mark_read", - "notification.open", - "notification.jump_to_unread", - "app.focus_override.set", - "app.simulate_active", - "file.open", - "markdown.open", - "browser.open_split", - "browser.navigate", - "browser.back", - "browser.forward", - "browser.reload", - "browser.react_grab.toggle", - "browser.devtools.toggle", - "browser.console.show", - "browser.focus_mode.set", - "browser.zoom.set", - "browser.history.clear", - "browser.url.get", - "browser.snapshot", - "browser.eval", - "browser.wait", - "browser.click", - "browser.dblclick", - "browser.hover", - "browser.focus", - "browser.type", - "browser.fill", - "browser.press", - "browser.keydown", - "browser.keyup", - "browser.check", - "browser.uncheck", - "browser.select", - "browser.scroll", - "browser.scroll_into_view", - "browser.screenshot", - "browser.get.text", - "browser.get.html", - "browser.get.value", - "browser.get.attr", - "browser.get.title", - "browser.get.count", - "browser.get.box", - "browser.get.styles", - "browser.is.visible", - "browser.is.enabled", - "browser.is.checked", - "browser.focus_webview", - "browser.is_webview_focused", - "browser.find.role", - "browser.find.text", - "browser.find.label", - "browser.find.placeholder", - "browser.find.alt", - "browser.find.title", - "browser.find.testid", - "browser.find.first", - "browser.find.last", - "browser.find.nth", - "browser.frame.select", - "browser.frame.main", - "browser.dialog.accept", - "browser.dialog.dismiss", - "browser.download.wait", - "browser.cookies.get", - "browser.cookies.set", - "browser.cookies.clear", - "browser.storage.get", - "browser.storage.set", - "browser.storage.clear", - "browser.tab.new", - "browser.tab.list", - "browser.tab.switch", - "browser.tab.close", - "browser.console.list", - "browser.console.clear", - "browser.errors.list", - "browser.highlight", - "browser.state.save", - "browser.state.load", - "browser.addinitscript", - "browser.addscript", - "browser.addstyle", - "browser.viewport.set", - "browser.geolocation.set", - "browser.offline.set", - "browser.trace.start", - "browser.trace.stop", - "browser.network.route", - "browser.network.unroute", - "browser.network.requests", - "browser.screencast.start", - "browser.screencast.stop", - "browser.input_mouse", - "browser.input_keyboard", - "browser.input_touch", - ] -#if DEBUG - methods.append(contentsOf: Self.v2DebugMethodNames) -#endif - - return [ - "protocol": "cmux-socket", - "version": 2, - "socket_path": socketServer.currentSocketPath, - "access_mode": socketServer.accessMode.rawValue, - "methods": methods.sorted() - ] - } - func v2Identify(params: [String: Any]) -> [String: Any] { guard let tabManager = v2ResolveTabManager(params: params) else { return [ @@ -2800,15 +2566,11 @@ class TerminalController { } v2AttachTopApplicationProcess(to: &windowNodes) - let processSnapshot = await withTaskGroup( - of: CmuxTopProcessSnapshot.self, - returning: CmuxTopProcessSnapshot.self - ) { group in - group.addTask(priority: .utility) { - CmuxTopProcessSnapshot.capture(includeProcessDetails: includeProcesses) - } - return await group.next()! - } + let requirements: CmuxTopProcessSnapshotRequirements = includeProcesses ? [.processDetails, .cmuxScope] : .cmuxScope + let processSnapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: requirements, + maximumAge: 1, consumer: .systemTop + ) let browserPIDOccurrences = v2TopBrowserPIDOccurrences(in: windowNodes) var annotatedWindows = windowNodes let totalPIDs = v2AnnotateTopWindows( @@ -2845,7 +2607,7 @@ class TerminalController { ) } - private nonisolated func v2SystemTop(params: [String: Any]) -> V2CallResult { + private nonisolated func v2SystemTop(params: [String: Any]) async -> V2CallResult { let base = v2MainSync { self.v2RefreshKnownRefs() return self.v2SystemTopBasePayload(params: params) @@ -2856,7 +2618,11 @@ class TerminalController { var windowNodes = payload.removeValue(forKey: "windows") as? [[String: Any]] else { return .err(code: "internal_error", message: "Invalid system.top payload", data: nil) } - let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: includeProcesses) + let requirements: CmuxTopProcessSnapshotRequirements = includeProcesses ? [.processDetails, .cmuxScope] : .cmuxScope + let processSnapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: requirements, + maximumAge: 1, consumer: .systemTop + ) let browserPIDOccurrences = v2TopBrowserPIDOccurrences(in: windowNodes) let totalPIDs = v2AnnotateTopWindows( &windowNodes, @@ -2879,7 +2645,7 @@ class TerminalController { return .ok(payload) } - private nonisolated func v2SystemMemory(params: [String: Any]) -> V2CallResult { + private nonisolated func v2SystemMemory(params: [String: Any]) async -> V2CallResult { var baseParams = params baseParams["include_processes"] = false let base = v2MainSync { @@ -2932,9 +2698,9 @@ class TerminalController { return .err(code: "invalid_params", message: "\(invalidLimitKey) must be an integer from 1 to 100", data: nil) } let topGroupLimit = topGroupLimitValue ?? groupLimitValue ?? 12 - let processSnapshot = CmuxTopProcessSnapshot.captureCached( - includeProcessDetails: true, - maximumAge: 2 + let processSnapshot = await CmuxTopProcessSnapshotStore.shared.snapshot( + requirements: [.processDetails, .cmuxScope], + maximumAge: 2, consumer: .systemTop ) let browserPIDOccurrences = v2TopBrowserPIDOccurrences(in: windowNodes) _ = v2AnnotateTopWindows( @@ -12164,7 +11930,10 @@ class TerminalController { if let fastPath { // The surface's current workspace wins over the claimed one (the // sync deliverer retargets); only a target gone everywhere errors. - guard AppDelegate.shared?.agentNotificationDeliveryTarget(claimedTabId: fastPath.workspaceId, surfaceId: fastPath.panelId) != nil else { + guard AppDelegate.shared?.agentNotificationDeliveryTarget( + claimedTabId: fastPath.workspaceId, + surfaceId: fastPath.panelId + ) != nil else { return "ERROR: Panel not found" } self.deliverNotificationSynchronously( @@ -12187,7 +11956,10 @@ class TerminalController { return "ERROR: Tab not found" } guard let panelId = UUID(uuidString: panelArg), - AppDelegate.shared?.agentNotificationDeliveryTarget(claimedTabId: tab.id, surfaceId: panelId) != nil else { + AppDelegate.shared?.agentNotificationDeliveryTarget( + claimedTabId: tab.id, + surfaceId: panelId + ) != nil else { return "ERROR: Panel not found" } self.deliverNotificationSynchronously( @@ -12328,7 +12100,8 @@ class TerminalController { TerminalNotificationStore.shared.clearNotifications( forTabId: tab.id, surfaceId: panelId, - discardQueuedNotifications: false, throughNotificationGeneration: clearBoundary + discardQueuedNotifications: false, + throughNotificationGeneration: clearBoundary ) } else { TerminalMutationBus.shared.discardPendingNotifications( @@ -12337,7 +12110,8 @@ class TerminalController { ) TerminalNotificationStore.shared.clearNotifications( forTabId: tab.id, - discardQueuedNotifications: false, throughNotificationGeneration: clearBoundary + discardQueuedNotifications: false, + throughNotificationGeneration: clearBoundary ) } } diff --git a/Sources/VaultAgentProcessCandidateSelector.swift b/Sources/VaultAgentProcessCandidateSelector.swift new file mode 100644 index 000000000000..53e283d9e2b2 --- /dev/null +++ b/Sources/VaultAgentProcessCandidateSelector.swift @@ -0,0 +1,120 @@ +import Foundation + +struct VaultAgentProcessCandidateSelector { + let processIDs: Set + private let unconstrainedArgumentNeedles: [[UInt8]] + + init( + processes: [CmuxTopProcessInfo], + registry: CmuxVaultAgentRegistry + ) { + unconstrainedArgumentNeedles = Self.unconstrainedArgumentNeedles(in: registry).map { + Array($0.replacingOccurrences(of: "\\", with: "/").lowercased().utf8) + } + guard Self.canUseBuiltInFastPath(registry: registry) else { + processIDs = Set(processes.map(\.pid)) + return + } + + let registeredBasenames = Self.registeredBasenames(in: registry) + processIDs = Set(processes.compactMap { process in + Self.isCandidate(process, registeredBasenames: registeredBasenames) + ? process.pid + : nil + }) + } + + func contains(_ processID: Int) -> Bool { + processIDs.contains(processID) + } + + func rawArgumentsMayMatchUnconstrainedRule(_ bytes: [UInt8]) -> Bool { + CmuxTopProcessSnapshot.processArgumentsContainAnyNeedle( + fromKernProcArgs: bytes, + normalizedNeedles: unconstrainedArgumentNeedles + ) + } + + private static func unconstrainedArgumentNeedles( + in registry: CmuxVaultAgentRegistry + ) -> [String] { + registry.registrations.flatMap { registration in + let rule = registration.detect + var needles: [String] = [] + if rule.processName == nil, rule.processNames.isEmpty { + needles.append(contentsOf: rule.argvContains) + } + if rule.alternateProcessNames.isEmpty { + needles.append(contentsOf: rule.alternateArgvContains) + needles.append(contentsOf: rule.alternateArgvContainsAny) + } + return needles + } + } + + private static func canUseBuiltInFastPath(registry: CmuxVaultAgentRegistry) -> Bool { + let builtInsByID = Dictionary( + uniqueKeysWithValues: builtInRegistrations.map { ($0.id, $0) } + ) + return registry.registrations.allSatisfy { registration in + builtInsByID[registration.id] == registration + } + } + + private static func registeredBasenames(in registry: CmuxVaultAgentRegistry) -> Set { + Set(registry.registrations.flatMap { registration in + let rule = registration.detect + return ([rule.processName].compactMap { $0 } + + rule.processNames + + rule.alternateProcessNames) + .compactMap(normalizedBasename) + }) + } + + private static func isCandidate( + _ process: CmuxTopProcessInfo, + registeredBasenames: Set + ) -> Bool { + if process.isTerminalForegroundProcessGroup { + return true + } + if CmuxTaskManagerCodingAgentDefinition.shouldReadArguments( + processName: process.name, + processPath: process.path + ) { + return true + } + + let basenames = [process.name, process.path] + .compactMap { $0 } + .compactMap(normalizedBasename) + return basenames.contains { basename in + registeredBasenames.contains(basename) + || builtInAgentBasenames.contains(basename) + || wrapperBasenames.contains(basename) + } + } + + private static func normalizedBasename(_ value: String) -> String? { + let basename = (value as NSString).lastPathComponent + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return basename.isEmpty ? nil : basename + } + + private static let builtInRegistrations: [CmuxVaultAgentRegistration] = [ + .builtInPi, + .builtInOmp, + .builtInCampfire, + .builtInAntigravity, + .builtInGrok, + ] + + private static let builtInAgentBasenames = Set( + CmuxTaskManagerCodingAgentDefinition.builtIns + .flatMap(\.directBasenames) + .compactMap(normalizedBasename) + ).union([".opencode"]) + + private static let wrapperBasenames: Set = ["cmux"] +} diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 3210e27a8643..d2df62552611 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -29,36 +29,104 @@ extension AgentLaunchCommandSnapshot { } extension RestorableAgentSessionIndex { - static func processDetectedSnapshots( - registry: CmuxVaultAgentRegistry, - fileManager: FileManager - ) -> [PanelKey: ProcessDetectedSnapshotEntry] { - let capturedAt = Date().timeIntervalSince1970 - let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: true) - return processDetectedSnapshots( - registry: registry, - fileManager: fileManager, - processSnapshot: processSnapshot, - capturedAt: capturedAt - ) - } - static func processDetectedSnapshots( registry: CmuxVaultAgentRegistry, fileManager: FileManager, processSnapshot: CmuxTopProcessSnapshot, capturedAt: TimeInterval, - processArgumentsProvider: (Int) -> CmuxTopProcessArguments? = { - CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: $0) + processArgumentsProvider: ((Int) -> CmuxTopProcessArguments?)? = nil, + processArgumentBytesProvider: (Int) -> [UInt8]? = { + CmuxTopProcessSnapshot.kernProcArgsBytes(for: $0) + }, + processArgumentsDecoder: ([UInt8]) -> CmuxTopProcessArguments? = { + CmuxTopProcessSnapshot.processArgumentsAndEnvironment(fromKernProcArgs: $0) } ) -> [PanelKey: ProcessDetectedSnapshotEntry] { - // KERN_PROCARGS2 argv/env decoding is the expensive unit of this scan; memoize so - // the OpenCode, fork-parent-fallback, and registry passes read each pid once. - // updateValue (not subscript) so a nil miss is unambiguously stored, not removed. +#if DEBUG + let filterMetricsToken = ProcessPerformanceMetrics.shared.operationStarted( + .vaultFilter, + inputCount: processSnapshot.processesByPID.count + ) +#endif + let scopedProcesses = processSnapshot.cmuxScopedProcesses() + let initialArgumentCandidates = VaultAgentProcessCandidateSelector( + processes: scopedProcesses, + registry: registry + ) + var argumentCandidateProcessIDs = initialArgumentCandidates.processIDs + var registriesByWorkingDirectory: [String: CmuxVaultAgentRegistry] = [:] + + func registryForWorkingDirectory(_ workingDirectory: String?) -> CmuxVaultAgentRegistry { + guard let workingDirectory else { return registry } + let key = (workingDirectory as NSString).standardizingPath + if let cached = registriesByWorkingDirectory[key] { + return cached + } + let resolved = registry.mergingProjectConfig( + workingDirectory: key, + fileManager: fileManager + ) + registriesByWorkingDirectory[key] = resolved + return resolved + } + + // An injected decoded provider has no raw environment for exact project-registry + // selection, so it retains exhaustive legacy semantics. Production reads every + // scoped KERN_PROCARGS2 buffer once, allocation-light scans the two cwd keys, and + // retains only candidate buffers for their one full decode below. + let usesInjectedProcessArguments = processArgumentsProvider != nil + if usesInjectedProcessArguments { + argumentCandidateProcessIDs = Set(scopedProcesses.map(\.pid)) + } + var rawProcessArgumentsByPID: [Int: [UInt8]] = [:] + let selectorIsExhaustive = argumentCandidateProcessIDs.count == scopedProcesses.count + if !usesInjectedProcessArguments, !selectorIsExhaustive { + let needsProjectRegistryDiscovery = !registry.registrations.isEmpty + for process in scopedProcesses { + if registry.registrations.isEmpty, + !argumentCandidateProcessIDs.contains(process.pid) { + continue + } + guard let bytes = processArgumentBytesProvider(process.pid) else { continue } + if needsProjectRegistryDiscovery { + let workingDirectory = normalized( + CmuxTopProcessSnapshot.processProjectWorkingDirectory(fromKernProcArgs: bytes) + ) + let processRegistry = registryForWorkingDirectory(workingDirectory) + if processRegistry.registrations != registry.registrations { + argumentCandidateProcessIDs.insert(process.pid) + } + } + if !argumentCandidateProcessIDs.contains(process.pid), + initialArgumentCandidates.rawArgumentsMayMatchUnconstrainedRule(bytes) { + argumentCandidateProcessIDs.insert(process.pid) + } + if argumentCandidateProcessIDs.contains(process.pid) { + rawProcessArgumentsByPID[process.pid] = bytes + } + } + } + + // KERN_PROCARGS2 argv/env decoding is the expensive unit of this scan. Ignore + // processes whose kernel identity cannot match a built-in detector, then memoize + // so the OpenCode, fork-parent-fallback, and registry passes read each candidate + // once. A custom registration disables filtering in the selector because argv-only + // rules can match a process with otherwise unrelated kernel metadata. var processArgumentsByPID: [Int: CmuxTopProcessArguments?] = [:] func cachedProcessArguments(_ processID: Int) -> CmuxTopProcessArguments? { + guard argumentCandidateProcessIDs.contains(processID) else { return nil } if let cached = processArgumentsByPID[processID] { return cached } - let resolved = processArgumentsProvider(processID) + let resolved: CmuxTopProcessArguments? + if let processArgumentsProvider { + resolved = processArgumentsProvider(processID) + } else if let bytes = rawProcessArgumentsByPID.removeValue(forKey: processID) { + resolved = processArgumentsDecoder(bytes) + } else if selectorIsExhaustive, + let bytes = processArgumentBytesProvider(processID) { + resolved = processArgumentsDecoder(bytes) + } else { + resolved = nil + } processArgumentsByPID.updateValue(resolved, forKey: processID) return resolved } @@ -78,24 +146,16 @@ extension RestorableAgentSessionIndex { processArgumentsProvider: cachedProcessArguments )) { existing, _ in existing } resolved.merge(processDetectedForkParentFallbackSnapshots(processSnapshot: processSnapshot, capturedAt: capturedAt, scopedProcessIDsByPanelKey: scopedProcessIDsByPanelKey, processArgumentsProvider: cachedProcessArguments)) { existing, _ in existing } - guard !registry.registrations.isEmpty else { return resolved } - var registriesByWorkingDirectory: [String: CmuxVaultAgentRegistry] = [:] - - func registryForWorkingDirectory(_ workingDirectory: String?) -> CmuxVaultAgentRegistry { - guard let workingDirectory else { return registry } - let key = (workingDirectory as NSString).standardizingPath - if let cached = registriesByWorkingDirectory[key] { - return cached - } - let resolved = registry.mergingProjectConfig( - workingDirectory: key, - fileManager: fileManager + guard !registry.registrations.isEmpty else { +#if DEBUG + ProcessPerformanceMetrics.shared.operationCompleted( + filterMetricsToken, + outputCount: resolved.count ) - registriesByWorkingDirectory[key] = resolved +#endif return resolved } - - for process in processSnapshot.cmuxScopedProcesses() { + for process in scopedProcesses { guard let workspaceId = process.cmuxWorkspaceID, let panelId = process.cmuxSurfaceID, let processArguments = cachedProcessArguments(process.pid) else { @@ -108,6 +168,7 @@ extension RestorableAgentSessionIndex { environment: processArguments.environment ) let cwd = normalized(observed.environment["CMUX_AGENT_LAUNCH_CWD"] ?? observed.environment["PWD"]) + let key = PanelKey(workspaceId: workspaceId, panelId: panelId) let processRegistry = registryForWorkingDirectory(cwd) guard let registration = processRegistry.registrations.first(where: { $0.detect.matches(observed) }), registration.processDetectedSnapshotIsRestorable(for: observed), @@ -146,7 +207,6 @@ extension RestorableAgentSessionIndex { ), registration: registration ) - let key = PanelKey(workspaceId: workspaceId, panelId: panelId) resolved[key] = ( snapshot: snapshot, updatedAt: capturedAt, @@ -156,6 +216,12 @@ extension RestorableAgentSessionIndex { ) } +#if DEBUG + ProcessPerformanceMetrics.shared.operationCompleted( + filterMetricsToken, + outputCount: resolved.count + ) +#endif return resolved } @@ -600,19 +666,6 @@ extension RestorableAgentSessionIndex { } extension SurfaceResumeBindingIndex { - static func processDetectedTmuxBindings( - fileManager: FileManager - ) -> [PanelKey: (binding: SurfaceResumeBindingSnapshot, updatedAt: TimeInterval)] { - _ = fileManager - let capturedAt = Date().timeIntervalSince1970 - let processSnapshot = CmuxTopProcessSnapshot.capture(includeProcessDetails: true) - return processDetectedTmuxBindings( - fileManager: fileManager, - processSnapshot: processSnapshot, - capturedAt: capturedAt - ) - } - static func processDetectedTmuxBindings( fileManager: FileManager, processSnapshot: CmuxTopProcessSnapshot, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f30314ad50a3..64ce97fd1a2b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2916,7 +2916,8 @@ final class Workspace: Identifiable, ObservableObject { self.currentDirectory = initialDirectory self.surfaceTabBarDirectory = initialDirectory - // Preserve terminal state and inherit tab-strip sizing without repeated config parsing. + // Panel models own restoration state, so hidden tabs need no parallel SwiftUI tree. + // Mounting only the selected tab keeps updates proportional to visible panes. let initialSurfaceTabBarFontSize = GhosttyConfig.load(globalFontMagnificationPercent: GlobalFontMagnification.storedPercent).surfaceTabBarFontSize let appearance = Self.bonsplitAppearance( from: GhosttyApp.shared.defaultBackgroundColor, @@ -2930,7 +2931,7 @@ final class Workspace: Identifiable, ObservableObject { allowTabReordering: true, allowCrossPaneTabMove: true, autoCloseEmptyPanes: true, - contentViewLifecycle: .keepAllAlive, + contentViewLifecycle: .recreateOnSwitch, newTabPosition: .current, appearance: appearance ) @@ -11558,11 +11559,9 @@ extension Workspace: BonsplitDelegate { p.unfocus() } - // Explicitly hide browser portals for deselected tabs in this pane. - // Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), - // but portal-hosted WKWebViews render at the window level in AppKit and are not - // affected by SwiftUI opacity. Without an explicit hide, the deselected browser's - // portal layer can remain visible above the newly selected tab. + // Explicitly hide browser portals for deselected tabs in this pane. The WKWebView lives + // at the AppKit window level, so removing its SwiftUI host does not synchronously hide + // the portal layer during a tab-selection transition. hideBrowserPortalsForDeselectedTabs(inPane: focusedPane, selectedTabId: selectedTabId) if let focusWindow = activationWindow(for: panel) { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7e0abc87fde4..1ed5d0665c23 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ C7A52D100000000000000002 /* AgentChatSessionRegistry+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52D100000000000000001 /* AgentChatSessionRegistry+Lifecycle.swift */; }; C7A52D200000000000000002 /* AgentChatSessionRegistry+LiveAgentPID.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52D200000000000000001 /* AgentChatSessionRegistry+LiveAgentPID.swift */; }; C7A52D000000000000000002 /* AgentChatSessionRegistry+ObserveScan.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52D000000000000000001 /* AgentChatSessionRegistry+ObserveScan.swift */; }; + 7863A0020000000000000002 /* AgentChatSessionRegistry+ProcessClassification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0020000000000000001 /* AgentChatSessionRegistry+ProcessClassification.swift */; }; ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */; }; C7A51F000000000000000002 /* AgentChatSessionRegistryClaudeObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51F000000000000000001 /* AgentChatSessionRegistryClaudeObservationTests.swift */; }; C7A51C000000000000000002 /* AgentChatSessionRegistryHookStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A51C000000000000000001 /* AgentChatSessionRegistryHookStoreTests.swift */; }; @@ -191,6 +192,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A4ECF22C62724853A72D6BDE /* AutoNamingGrokAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07EC52007B2144C59C047774 /* AutoNamingGrokAdapterTests.swift */; }; 74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */; }; C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */; }; + C0DE35870000000000000001 /* BackgroundWorkspacePrimeCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35870000000000000002 /* BackgroundWorkspacePrimeCoordinatorTests.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */ = {isa = PBXBuildFile; productRef = A5001262 /* Bonsplit */; }; 74060000000000000000001B /* BonsplitConfiguration+RemoteTmuxEmbedded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74060000000000000000001A /* BonsplitConfiguration+RemoteTmuxEmbedded.swift */; }; @@ -267,6 +269,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D0B1001EA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001FA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift */; }; D7632001A1B2C3D4E5F60718 /* BrowserPaneFileDropRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7632002A1B2C3D4E5F60718 /* BrowserPaneFileDropRouting.swift */; }; D7632003A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7632004A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift */; }; + 7863A0030000000000000002 /* BrowserPanel+AddressBarFocusOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0030000000000000001 /* BrowserPanel+AddressBarFocusOwnership.swift */; }; C65241010000000000000001 /* BrowserPanel+AppPricingRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65241010000000000000002 /* BrowserPanel+AppPricingRestore.swift */; }; C3711E000000000000000001 /* BrowserPanel+DisplayURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3711E000000000000000002 /* BrowserPanel+DisplayURL.swift */; }; D7632B11A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7632B12A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift */; }; @@ -276,9 +279,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B424PWAD000000000000PW01 /* BrowserPanel+PrewarmedWebViewAdoption.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424PWAD000000000000PW02 /* BrowserPanel+PrewarmedWebViewAdoption.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; B1F0C0090000000000000001 /* BrowserPanelDeveloperToolsLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0090000000000000002 /* BrowserPanelDeveloperToolsLifecycle.swift */; }; + 7863B0010000000000000002 /* BrowserPanelFocusAndAppearanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0010000000000000001 /* BrowserPanelFocusAndAppearanceTests.swift */; }; C62530010000000000000001 /* BrowserPanelReloadMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62530010000000000000002 /* BrowserPanelReloadMode.swift */; }; B65060010000000000000002 /* BrowserPanelSessionRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; + 7863A0040000000000000002 /* BrowserPanelView+AddressBarFocusRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0040000000000000001 /* BrowserPanelView+AddressBarFocusRequest.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0B1003EA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1003FA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift */; }; @@ -600,6 +605,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C7A511000000000000000002 /* CmuxTopProcessEnumeration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A511000000000000000001 /* CmuxTopProcessEnumeration.swift */; }; 0A1107110000000000000018 /* CmuxTopProcessSnapshot+PromptAgentDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000019 /* CmuxTopProcessSnapshot+PromptAgentDetection.swift */; }; C7A512000000000000000002 /* CmuxTopProcessSnapshotCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A512000000000000000001 /* CmuxTopProcessSnapshotCache.swift */; }; + C7A513000000000000000002 /* CmuxTopProcessSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A513000000000000000001 /* CmuxTopProcessSnapshotStoreTests.swift */; }; C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A501000000000000000001 /* CmuxTopSnapshot.swift */; }; C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */; }; C7A5090000000000000005A2 /* CmuxTopSnapshotScopeCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */; }; @@ -775,6 +781,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources DD62AFF1D95A1D5D1850AC10 /* FilePreviewQuickLookSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A02623202562490B3E88372 /* FilePreviewQuickLookSession.swift */; }; C0DE31390000000000000103 /* FilePreviewReviewFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */; }; D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; }; + F17EED170000000000000001 /* FilePreviewTextEditorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EED170000000000000002 /* FilePreviewTextEditorSession.swift */; }; C0DEFC100000000000000001 /* FilePreviewTextEditorTextKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFC100000000000000002 /* FilePreviewTextEditorTextKitTests.swift */; }; F11E52270000000000005227 /* FilePreviewWordWrapSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E52270000000000005228 /* FilePreviewWordWrapSettings.swift */; }; A5001445A5001445A5001445 /* FilePreviewWorkspaceOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */; }; @@ -797,6 +804,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F17F00000000000000000001 /* FullWidthTabPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17F00000000000000000002 /* FullWidthTabPersistenceTests.swift */; }; D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */; }; EC010D01 /* GhosttyApp+TerminalCustomUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010D02 /* GhosttyApp+TerminalCustomUpload.swift */; }; + 7863B0020000000000000002 /* GhosttyBackgroundAndPanelAppearanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0020000000000000001 /* GhosttyBackgroundAndPanelAppearanceTests.swift */; }; 1718C0DE1718C0DE17180002 /* GhosttyCommandShiftForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */; }; A7206D010000000000000001 /* GhosttyConfig+AppearanceSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206D020000000000000001 /* GhosttyConfig+AppearanceSync.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; @@ -819,6 +827,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */; }; C7261F030000000000000001 /* GhosttyNSView+PointerFocusActivation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7261F030000000000000002 /* GhosttyNSView+PointerFocusActivation.swift */; }; 265FFB2D782433ED46B87F32 /* GhosttyNSView+RemoteReconnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47895BD8BBB2418438E708F5 /* GhosttyNSView+RemoteReconnect.swift */; }; + 7863A0010000000000000002 /* GhosttyNSView+RendererProfiling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0010000000000000001 /* GhosttyNSView+RendererProfiling.swift */; }; C75100010000000000000001 /* GhosttyNSView+SelectionTranslation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75100010000000000000002 /* GhosttyNSView+SelectionTranslation.swift */; }; EC010E01 /* GhosttyNSView+TerminalCustomUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010E02 /* GhosttyNSView+TerminalCustomUpload.swift */; }; C54860070000000000000001 /* GhosttyNSViewSurfaceLinkCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860070000000000000002 /* GhosttyNSViewSurfaceLinkCopy.swift */; }; @@ -860,6 +869,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; C0DEA7720000000000000001 /* InternalFlagsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7720000000000000002 /* InternalFlagsWindow.swift */; }; 4D8B37E0E11A29997632765F /* InternalTabDragConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */; }; + B17C00010000000000000001 /* InvalidationBatchingBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17C00010000000000000002 /* InvalidationBatchingBehaviorTests.swift */; }; A5FB1307 /* JSONCObjectEditor+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB1309 /* JSONCObjectEditor+Remove.swift */; }; A5FB1308 /* JSONCObjectEditor+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB1309 /* JSONCObjectEditor+Remove.swift */; }; A5FB1311 /* JSONCObjectEditor+Set.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB1310 /* JSONCObjectEditor+Set.swift */; }; @@ -985,6 +995,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */; }; F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */; }; 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */; }; + A11CE0010000000000000001 /* MobileWorkspaceObserverMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0010000000000000002 /* MobileWorkspaceObserverMetrics.swift */; }; + A11CE0020000000000000001 /* MobileWorkspaceObserverMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0020000000000000002 /* MobileWorkspaceObserverMetricsTests.swift */; }; D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */; }; 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */; }; C75740020000000000000001 /* MouseDownMenuItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75740020000000000000002 /* MouseDownMenuItemView.swift */; }; @@ -1075,13 +1087,17 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B79500070000000000000001 /* PortScanner+PanelKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500070000000000000002 /* PortScanner+PanelKey.swift */; }; B79500010000000000000001 /* PortScanner+Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500010000000000000002 /* PortScanner+Process.swift */; }; B79500090000000000000001 /* PortScanner+Publication.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500090000000000000002 /* PortScanner+Publication.swift */; }; + 7863A0050000000000000002 /* PortScanner+SnapshotProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863A0050000000000000001 /* PortScanner+SnapshotProjection.swift */; }; A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; B79500320000000000000001 /* PortScannerIdentityContinuityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500320000000000000002 /* PortScannerIdentityContinuityTests.swift */; }; B79500300000000000000001 /* PortScannerPublicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500300000000000000002 /* PortScannerPublicationTests.swift */; }; + 7863B0040000000000000002 /* PortScannerSharedSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0040000000000000001 /* PortScannerSharedSnapshotTests.swift */; }; F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */; }; B79500100000000000000001 /* PortScanPublicationBatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500100000000000000002 /* PortScanPublicationBatch.swift */; }; B79500080000000000000001 /* PortScanPublicationBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500080000000000000002 /* PortScanPublicationBuffer.swift */; }; B79500060000000000000001 /* PortScanPublicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500060000000000000002 /* PortScanPublicationState.swift */; }; + C7A5F1000000000000000002 /* PortScanSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5F1000000000000000001 /* PortScanSnapshotStore.swift */; }; + 7863B0030000000000000002 /* PortScanSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0030000000000000001 /* PortScanSnapshotStoreTests.swift */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; B8E9500A0000000000000001 /* PostHogAnalyticsPropertiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */; }; @@ -1091,7 +1107,13 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DEA771000000000000000D /* PricingPlansScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA771000000000000000E /* PricingPlansScreen.swift */; }; C0DEA771000000000000000B /* ProBadgeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA771000000000000000C /* ProBadgeStyle.swift */; }; F0ACC0DE000000000000000D /* ProcessDetectedResumeIndexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */; }; + C7A5FF000000000000000004 /* ProcessPerformanceCaptureProof.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5FF000000000000000003 /* ProcessPerformanceCaptureProof.swift */; }; + C7A5FF000000000000000006 /* ProcessPerformanceExerciseGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5FF000000000000000005 /* ProcessPerformanceExerciseGate.swift */; }; + C7A5FF000000000000000002 /* ProcessPerformanceMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5FF000000000000000001 /* ProcessPerformanceMetrics.swift */; }; + 7863B0050000000000000002 /* ProcessPerformanceMetricsEpochTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0050000000000000001 /* ProcessPerformanceMetricsEpochTests.swift */; }; C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */; }; + C7AC31000000000000000002 /* ProcessSnapshotCentralizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AC31000000000000000001 /* ProcessSnapshotCentralizationTests.swift */; }; + 7863B0060000000000000002 /* ProcessSnapshotTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7863B0060000000000000001 /* ProcessSnapshotTestSupport.swift */; }; D75280010000000000000001 /* ProfilingSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280010000000000000002 /* ProfilingSignposts.swift */; }; A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */; }; A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */; }; @@ -1502,7 +1524,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A5720BC9A5720BC9A5720B /* TabItemView+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */; }; A5F100000000000000000005 /* TabItemView+WorkspaceNotificationsMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F100000000000000000006 /* TabItemView+WorkspaceNotificationsMenu.swift */; }; 186A0F2EE1C92446E0A2D1E3 /* TabItemView+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56EA28B32C38ED8C753C82D3 /* TabItemView+WorkspaceTodo.swift */; }; - 7136B0017136B0017136B001 /* TabManager+BackgroundWorkspaceMountPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */; }; DCDC1000000000000000B015 /* TabManager+BrowserFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; E3309A03 /* TabManager+EqualizeSplits.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3309A04 /* TabManager+EqualizeSplits.swift */; }; @@ -1515,7 +1536,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604500100000000000000008 /* TabManager+WindowTitle.swift */; }; 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; - F8120F5386FBE726864D340B /* TabManagerBackgroundWorkspaceMountBoundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481E6848F8513F27E496F7BE /* TabManagerBackgroundWorkspaceMountBoundTests.swift */; }; C0DE64160000000000000001 /* TabManagerNotificationFocusRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64160000000000000002 /* TabManagerNotificationFocusRegressionTests.swift */; }; 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; C06552030000000000000001 /* TabManagerTitleUpdateStalenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06552030000000000000002 /* TabManagerTitleUpdateStalenessTests.swift */; }; @@ -1531,6 +1551,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; E4D1768B7041CDE4F9A084A4 /* TerminalClearScreenKeepScrollbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; + C4A100000000000000000001 /* TerminalController+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A100000000000000000002 /* TerminalController+Capabilities.swift */; }; C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */; }; C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */; }; CA52E0020000000000000000 /* TerminalController+ControlCanvasContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52F0020000000000000000 /* TerminalController+ControlCanvasContext.swift */; }; @@ -1541,6 +1562,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */; }; C0DE00000000000000000C56 /* TerminalController+ControlPaneContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */; }; C0DE00000000000000000D84 /* TerminalController+ControlPaneDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000D83 /* TerminalController+ControlPaneDock.swift */; }; + C0DE00000000000000000C7E /* TerminalController+ControlPerformanceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C7D /* TerminalController+ControlPerformanceContext.swift */; }; C0DE00000000000000000C70 /* TerminalController+ControlProjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C6F /* TerminalController+ControlProjectContext.swift */; }; C0DE00000000000000000C72 /* TerminalController+ControlSidebarContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C71 /* TerminalController+ControlSidebarContext.swift */; }; C0DE00000000000000000C74 /* TerminalController+ControlSidebarContext2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C73 /* TerminalController+ControlSidebarContext2.swift */; }; @@ -1698,6 +1720,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; C2035A070000000000000001 /* URLRequest+BrowserFailedNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2035A070000000000000002 /* URLRequest+BrowserFailedNavigation.swift */; }; + A6C400010000000000000002 /* VaultAgentProcessCandidateSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C400010000000000000001 /* VaultAgentProcessCandidateSelector.swift */; }; + A6C400020000000000000002 /* VaultAgentProcessCandidateSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C400020000000000000001 /* VaultAgentProcessCandidateSelectorTests.swift */; }; F76550040000000000000001 /* VaultAgentProcessScanner+CodexSessionCwd.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76550040000000000000002 /* VaultAgentProcessScanner+CodexSessionCwd.swift */; }; F76550010000000000000001 /* VaultAgentProcessScanner+ForkMarkers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76550010000000000000002 /* VaultAgentProcessScanner+ForkMarkers.swift */; }; F0F0CF0C0000000000000001 /* VaultAgentProcessScanner+ForkParentFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F0CF0C0000000000000002 /* VaultAgentProcessScanner+ForkParentFallback.swift */; }; @@ -1938,6 +1962,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C7A52D100000000000000001 /* AgentChatSessionRegistry+Lifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+Lifecycle.swift"; sourceTree = ""; }; C7A52D200000000000000001 /* AgentChatSessionRegistry+LiveAgentPID.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+LiveAgentPID.swift"; sourceTree = ""; }; C7A52D000000000000000001 /* AgentChatSessionRegistry+ObserveScan.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+ObserveScan.swift"; sourceTree = ""; }; + 7863A0020000000000000001 /* AgentChatSessionRegistry+ProcessClassification.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry+ProcessClassification.swift"; sourceTree = ""; }; ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry.swift"; sourceTree = ""; }; C7A51F000000000000000001 /* AgentChatSessionRegistryClaudeObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryClaudeObservationTests.swift; sourceTree = ""; }; C7A51C000000000000000001 /* AgentChatSessionRegistryHookStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatSessionRegistryHookStoreTests.swift; sourceTree = ""; }; @@ -2105,6 +2130,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 07EC52007B2144C59C047774 /* AutoNamingGrokAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingGrokAdapterTests.swift; sourceTree = ""; }; 46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingHookPayloadAdapterTests.swift; sourceTree = ""; }; C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkspacePrimeCoordinator.swift; sourceTree = ""; }; + C0DE35870000000000000002 /* BackgroundWorkspacePrimeCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkspacePrimeCoordinatorTests.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; 74060000000000000000001A /* BonsplitConfiguration+RemoteTmuxEmbedded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BonsplitConfiguration+RemoteTmuxEmbedded.swift"; sourceTree = ""; }; D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarDebug.swift; sourceTree = ""; }; @@ -2180,6 +2206,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D0B1001FA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneDropTargetView.swift; sourceTree = ""; }; D7632002A1B2C3D4E5F60718 /* BrowserPaneFileDropRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneFileDropRouting.swift; sourceTree = ""; }; D7632004A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneFileDropUploadRegressionTests.swift; sourceTree = ""; }; + 7863A0030000000000000001 /* BrowserPanel+AddressBarFocusOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+AddressBarFocusOwnership.swift"; sourceTree = ""; }; C65241010000000000000002 /* BrowserPanel+AppPricingRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+AppPricingRestore.swift"; sourceTree = ""; }; C3711E000000000000000002 /* BrowserPanel+DisplayURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+DisplayURL.swift"; sourceTree = ""; }; D7632B12A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+InteractiveModalHostWindow.swift"; sourceTree = ""; }; @@ -2189,9 +2216,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B424PWAD000000000000PW02 /* BrowserPanel+PrewarmedWebViewAdoption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+PrewarmedWebViewAdoption.swift"; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; B1F0C0090000000000000002 /* BrowserPanelDeveloperToolsLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelDeveloperToolsLifecycle.swift; sourceTree = ""; }; + 7863B0010000000000000001 /* BrowserPanelFocusAndAppearanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelFocusAndAppearanceTests.swift; sourceTree = ""; }; C62530010000000000000002 /* BrowserPanelReloadMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelReloadMode.swift; sourceTree = ""; }; B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelSessionRestoreTests.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; + 7863A0040000000000000001 /* BrowserPanelView+AddressBarFocusRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanelView+AddressBarFocusRequest.swift"; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0B1003FA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneSplitTarget.swift; sourceTree = ""; }; @@ -2464,6 +2493,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A511000000000000000001 /* CmuxTopProcessEnumeration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopProcessEnumeration.swift; sourceTree = ""; }; 0A1107110000000000000019 /* CmuxTopProcessSnapshot+PromptAgentDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTopProcessSnapshot+PromptAgentDetection.swift"; sourceTree = ""; }; C7A512000000000000000001 /* CmuxTopProcessSnapshotCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopProcessSnapshotCache.swift; sourceTree = ""; }; + C7A513000000000000000001 /* CmuxTopProcessSnapshotStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopProcessSnapshotStoreTests.swift; sourceTree = ""; }; C7A501000000000000000001 /* CmuxTopSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshot.swift; sourceTree = ""; }; C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeCache.swift; sourceTree = ""; }; C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeCacheTests.swift; sourceTree = ""; }; @@ -2633,6 +2663,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 0A02623202562490B3E88372 /* FilePreviewQuickLookSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewQuickLookSession.swift; sourceTree = ""; }; C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewReviewFeedbackTests.swift; sourceTree = ""; }; D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewTextEditor.swift; sourceTree = ""; }; + F17EED170000000000000002 /* FilePreviewTextEditorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewTextEditorSession.swift; sourceTree = ""; }; C0DEFC100000000000000002 /* FilePreviewTextEditorTextKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextEditorTextKitTests.swift; sourceTree = ""; }; F11E52270000000000005228 /* FilePreviewWordWrapSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewWordWrapSettings.swift; sourceTree = ""; }; A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewWorkspaceOpenSupport.swift; sourceTree = ""; }; @@ -2656,6 +2687,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+SurfaceConfigurationReload.swift"; sourceTree = ""; }; EC010D02 /* GhosttyApp+TerminalCustomUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+TerminalCustomUpload.swift"; sourceTree = ""; }; + 7863B0020000000000000001 /* GhosttyBackgroundAndPanelAppearanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyBackgroundAndPanelAppearanceTests.swift; sourceTree = ""; }; 1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyCommandShiftForwardingTests.swift; sourceTree = ""; }; A7206D020000000000000001 /* GhosttyConfig+AppearanceSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyConfig+AppearanceSync.swift"; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; @@ -2678,6 +2710,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; C7261F030000000000000002 /* GhosttyNSView+PointerFocusActivation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+PointerFocusActivation.swift"; sourceTree = ""; }; 47895BD8BBB2418438E708F5 /* GhosttyNSView+RemoteReconnect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+RemoteReconnect.swift"; sourceTree = ""; }; + 7863A0010000000000000001 /* GhosttyNSView+RendererProfiling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+RendererProfiling.swift"; sourceTree = ""; }; C75100010000000000000002 /* GhosttyNSView+SelectionTranslation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+SelectionTranslation.swift"; sourceTree = ""; }; EC010E02 /* GhosttyNSView+TerminalCustomUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+TerminalCustomUpload.swift"; sourceTree = ""; }; C54860070000000000000002 /* GhosttyNSViewSurfaceLinkCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNSViewSurfaceLinkCopy.swift; sourceTree = ""; }; @@ -2719,6 +2752,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; C0DEA7720000000000000002 /* InternalFlagsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalFlagsWindow.swift; sourceTree = ""; }; 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/InternalTabDragConfiguration.swift; sourceTree = ""; }; + B17C00010000000000000002 /* InvalidationBatchingBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidationBatchingBehaviorTests.swift; sourceTree = ""; }; A5FB1309 /* JSONCObjectEditor+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONCObjectEditor+Remove.swift"; sourceTree = ""; }; A5FB1310 /* JSONCObjectEditor+Set.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONCObjectEditor+Set.swift"; sourceTree = ""; }; C0DEF0B10000000000000002 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = ""; }; @@ -2840,6 +2874,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalRenderObserver.swift; sourceTree = ""; }; 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceListFidelityTests.swift; sourceTree = ""; }; C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceListObserver.swift; sourceTree = ""; }; + A11CE0010000000000000002 /* MobileWorkspaceObserverMetrics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceObserverMetrics.swift; sourceTree = ""; }; + A11CE0020000000000000002 /* MobileWorkspaceObserverMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceObserverMetricsTests.swift; sourceTree = ""; }; D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceObserverSignposts.swift; sourceTree = ""; }; CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBridgeInputCapture.swift; sourceTree = ""; }; C75740020000000000000002 /* MouseDownMenuItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/MouseDownMenuItemView.swift; sourceTree = ""; }; @@ -2930,13 +2966,17 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B79500070000000000000002 /* PortScanner+PanelKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PortScanner+PanelKey.swift"; sourceTree = ""; }; B79500010000000000000002 /* PortScanner+Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PortScanner+Process.swift"; sourceTree = ""; }; B79500090000000000000002 /* PortScanner+Publication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PortScanner+Publication.swift"; sourceTree = ""; }; + 7863A0050000000000000001 /* PortScanner+SnapshotProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PortScanner+SnapshotProjection.swift"; sourceTree = ""; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; B79500320000000000000002 /* PortScannerIdentityContinuityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerIdentityContinuityTests.swift; sourceTree = ""; }; B79500300000000000000002 /* PortScannerPublicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerPublicationTests.swift; sourceTree = ""; }; + 7863B0040000000000000001 /* PortScannerSharedSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerSharedSnapshotTests.swift; sourceTree = ""; }; F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; B79500100000000000000002 /* PortScanPublicationBatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanPublicationBatch.swift; sourceTree = ""; }; B79500080000000000000002 /* PortScanPublicationBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanPublicationBuffer.swift; sourceTree = ""; }; B79500060000000000000002 /* PortScanPublicationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanPublicationState.swift; sourceTree = ""; }; + C7A5F1000000000000000001 /* PortScanSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanSnapshotStore.swift; sourceTree = ""; }; + 7863B0030000000000000001 /* PortScanSnapshotStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanSnapshotStoreTests.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; B8E9500A0000000000000002 /* PostHogAnalyticsPropertiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsPropertiesTests.swift; sourceTree = ""; }; D1F0A00100000000000000B2 /* PresenceHeartbeatClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PresenceHeartbeatClient.swift; sourceTree = ""; }; @@ -2945,7 +2985,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEA771000000000000000E /* PricingPlansScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingPlansScreen.swift; sourceTree = ""; }; C0DEA771000000000000000C /* ProBadgeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProBadgeStyle.swift; sourceTree = ""; }; F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetectedResumeIndexes.swift; sourceTree = ""; }; + C7A5FF000000000000000003 /* ProcessPerformanceCaptureProof.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPerformanceCaptureProof.swift; sourceTree = ""; }; + C7A5FF000000000000000005 /* ProcessPerformanceExerciseGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPerformanceExerciseGate.swift; sourceTree = ""; }; + C7A5FF000000000000000001 /* ProcessPerformanceMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPerformanceMetrics.swift; sourceTree = ""; }; + 7863B0050000000000000001 /* ProcessPerformanceMetricsEpochTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPerformanceMetricsEpochTests.swift; sourceTree = ""; }; C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPipeReadCrashRegressionTests.swift; sourceTree = ""; }; + C7AC31000000000000000001 /* ProcessSnapshotCentralizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessSnapshotCentralizationTests.swift; sourceTree = ""; }; + 7863B0060000000000000001 /* ProcessSnapshotTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessSnapshotTestSupport.swift; sourceTree = ""; }; D75280010000000000000002 /* ProfilingSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilingSignposts.swift; sourceTree = ""; }; A5C0DE0000000000000000B9 /* ProjectBuildSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectBuildSettingsTabView.swift; sourceTree = ""; }; A5C0DE0000000000000000B5 /* ProjectFilesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectFilesTabView.swift; sourceTree = ""; }; @@ -3342,7 +3388,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceGroups.swift"; sourceTree = ""; }; A5F100000000000000000006 /* TabItemView+WorkspaceNotificationsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceNotificationsMenu.swift"; sourceTree = ""; }; 56EA28B32C38ED8C753C82D3 /* TabItemView+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabItemView+WorkspaceTodo.swift"; sourceTree = ""; }; - 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+BackgroundWorkspaceMountPolicy.swift"; sourceTree = ""; }; DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+BrowserFocus.swift"; sourceTree = ""; }; D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+DetachedWorkspace.swift"; sourceTree = ""; }; E3309A04 /* TabManager+EqualizeSplits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+EqualizeSplits.swift"; sourceTree = ""; }; @@ -3355,7 +3400,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 604500100000000000000008 /* TabManager+WindowTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WindowTitle.swift"; sourceTree = ""; }; 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceCustomTitle.swift"; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; - 481E6848F8513F27E496F7BE /* TabManagerBackgroundWorkspaceMountBoundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerBackgroundWorkspaceMountBoundTests.swift; sourceTree = ""; }; C0DE64160000000000000002 /* TabManagerNotificationFocusRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerNotificationFocusRegressionTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; C06552030000000000000002 /* TabManagerTitleUpdateStalenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerTitleUpdateStalenessTests.swift; sourceTree = ""; }; @@ -3371,6 +3415,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClearScreenKeepScrollbackTests.swift; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; + C4A100000000000000000002 /* TerminalController+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Capabilities.swift"; sourceTree = ""; }; C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlAppFocusContext.swift"; sourceTree = ""; }; C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlBrowserPanelContext.swift"; sourceTree = ""; }; CA52F0020000000000000000 /* TerminalController+ControlCanvasContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlCanvasContext.swift"; sourceTree = ""; }; @@ -3381,6 +3426,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlNotificationContext.swift"; sourceTree = ""; }; C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlPaneContext.swift"; sourceTree = ""; }; C0DE00000000000000000D83 /* TerminalController+ControlPaneDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlPaneDock.swift"; sourceTree = ""; }; + C0DE00000000000000000C7D /* TerminalController+ControlPerformanceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlPerformanceContext.swift"; sourceTree = ""; }; C0DE00000000000000000C6F /* TerminalController+ControlProjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlProjectContext.swift"; sourceTree = ""; }; C0DE00000000000000000C71 /* TerminalController+ControlSidebarContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSidebarContext.swift"; sourceTree = ""; }; C0DE00000000000000000C73 /* TerminalController+ControlSidebarContext2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSidebarContext2.swift"; sourceTree = ""; }; @@ -3538,6 +3584,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = ""; }; C2035A070000000000000002 /* URLRequest+BrowserFailedNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/URLRequest+BrowserFailedNavigation.swift"; sourceTree = ""; }; + A6C400010000000000000001 /* VaultAgentProcessCandidateSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultAgentProcessCandidateSelector.swift; sourceTree = ""; }; + A6C400020000000000000001 /* VaultAgentProcessCandidateSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultAgentProcessCandidateSelectorTests.swift; sourceTree = ""; }; F76550040000000000000002 /* VaultAgentProcessScanner+CodexSessionCwd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentProcessScanner+CodexSessionCwd.swift"; sourceTree = ""; }; F76550010000000000000002 /* VaultAgentProcessScanner+ForkMarkers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentProcessScanner+ForkMarkers.swift"; sourceTree = ""; }; F0F0CF0C0000000000000002 /* VaultAgentProcessScanner+ForkParentFallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentProcessScanner+ForkParentFallback.swift"; sourceTree = ""; }; @@ -3894,6 +3942,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A52D100000000000000001 /* AgentChatSessionRegistry+Lifecycle.swift */, C7A52D200000000000000001 /* AgentChatSessionRegistry+LiveAgentPID.swift */, C7A52D000000000000000001 /* AgentChatSessionRegistry+ObserveScan.swift */, + 7863A0020000000000000001 /* AgentChatSessionRegistry+ProcessClassification.swift */, C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */, ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */, ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */, @@ -3925,6 +3974,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F9BC53FA4991A6EBFB930EDF /* MobilePairingView.swift */, 5AE701F43A0B49F08835191C /* MobilePairingWindowController.swift */, C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */, + A11CE0010000000000000002 /* MobileWorkspaceObserverMetrics.swift */, D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */, F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */, 4F05EBBC2FE0D5CB2F662B8A /* MobileTerminalByteTee.swift */, @@ -4261,7 +4311,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C06552010000000000000004 /* NotificationBurstCoalescer.swift */, C06552010000000000000002 /* PanelTitleUpdateCoalescingSettings.swift */, A5001013 /* TabManager.swift */, - 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */, C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */, C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */, DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */, @@ -4362,6 +4411,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C4041001000000000000001A /* CommandClickFileOpenRouter.swift */, C40410010000000000000020 /* FileExplorerDoubleClickActionSettings.swift */, A5001015 /* GhosttyTerminalView.swift */, + 7863A0010000000000000001 /* GhosttyNSView+RendererProfiling.swift */, A11E5E1E0000000000000002 /* TerminalSelectionAccessibilityNotifier.swift */, A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */, D78971000000000000000002 /* GhosttyNotificationScrollRuntimeBridge.swift */, @@ -4434,6 +4484,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */, E7E000000000000000000008 /* CmuxEventPublishing.swift */, A5001019 /* TerminalController.swift */, + C4A100000000000000000002 /* TerminalController+Capabilities.swift */, A28B087F000000000000000A /* DiffViewerSessionTrustRegistry.swift */, A28B087F0000000000000018 /* DiffViewerLiveHTTPSession.swift */, A28B087F000000000000000E /* DiffViewerNavigationDocumentState.swift */, @@ -4458,6 +4509,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */, C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */, C0DE00000000000000000C6D /* TerminalController+ControlDebugContext.swift */, + C0DE00000000000000000C7D /* TerminalController+ControlPerformanceContext.swift */, C0DEBEEF0000000000000001 /* TerminalController+DebugMethodNames.swift */, C0DE00000000000000000C6F /* TerminalController+ControlProjectContext.swift */, C0DE00000000000000000C71 /* TerminalController+ControlSidebarContext.swift */, @@ -4490,6 +4542,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A510000000000000000001 /* CmuxTopMemoryDiagnostics.swift */, C7A511000000000000000001 /* CmuxTopProcessEnumeration.swift */, C7A512000000000000000001 /* CmuxTopProcessSnapshotCache.swift */, + C7A5FF000000000000000003 /* ProcessPerformanceCaptureProof.swift */, + C7A5FF000000000000000005 /* ProcessPerformanceExerciseGate.swift */, + C7A5FF000000000000000001 /* ProcessPerformanceMetrics.swift */, B35750000000000000000003 /* CmuxTopProcessArguments.swift */, C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */, C7A50D000000000000000001 /* CmuxTopProcessCPUTracker.swift */, @@ -4540,6 +4595,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B79500080000000000000002 /* PortScanPublicationBuffer.swift */, B79500030000000000000002 /* PortScanCoordination.swift */, A5001541 /* PortScanner.swift */, + 7863A0050000000000000001 /* PortScanner+SnapshotProjection.swift */, + C7A5F1000000000000000001 /* PortScanSnapshotStore.swift */, B79500070000000000000002 /* PortScanner+PanelKey.swift */, B79500010000000000000002 /* PortScanner+Process.swift */, B79500310000000000000002 /* PortLsofScanResult.swift */, @@ -4678,6 +4735,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C67540120000000000000002 /* BrowserDownloadsToolbarButton.swift */, C67540100000000000000002 /* BrowserNavigationPopupPolicy.swift */, A5001412 /* BrowserPanel.swift */, + 7863A0030000000000000001 /* BrowserPanel+AddressBarFocusOwnership.swift */, B1F0C0090000000000000002 /* BrowserPanelDeveloperToolsLifecycle.swift */, C2035A010000000000000002 /* BrowserErrorPage.swift */, C2035A020000000000000002 /* BrowserErrorPageContent.swift */, @@ -4718,6 +4776,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */, 326E7A5E13C6DF650B9F8971 /* BrowserSystemProxyWatcher.swift */, A5001414 /* BrowserPanelView.swift */, + 7863A0040000000000000001 /* BrowserPanelView+AddressBarFocusRequest.swift */, C67540070000000000000002 /* BrowserPDFDocumentToolbarButtons.swift */, C42660010000000000000002 /* BrowserPDFPreviewActionUIDelegate.swift */, C42660020000000000000002 /* BrowserPDFPrintOperationRunner.swift */, @@ -4823,6 +4882,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 0A02623202562490B3E88372 /* FilePreviewQuickLookSession.swift */, C0DE64020000000000001003 /* TrackedQLPreviewView.swift */, D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */, + F17EED170000000000000002 /* FilePreviewTextEditorSession.swift */, A5001451A5001451A5001451 /* FilePreviewNativeBackground.swift */, A5001442A5001442A5001442 /* FilePreviewModeSupport.swift */, A5001444A5001444A5001444 /* FilePreviewWorkspaceOpenSupport.swift */, @@ -4999,6 +5059,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE003001 /* SessionIndexStore.swift */, B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */, CAFE58130000000000000002 /* CampfireLaunchArgumentNormalizer.swift */, + A6C400010000000000000001 /* VaultAgentProcessCandidateSelector.swift */, B35750000000000000000005 /* VaultAgentProcessScanner.swift */, B35750000000000000000011 /* VaultAgentProcessScannerDetectRules.swift */, B3575000000000000000000F /* VaultAgentProcessScannerSessionDirectories.swift */, @@ -5255,6 +5316,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F0F0CF0D0000000000000002 /* ForkParentFallbackSessionIndexTests.swift */, F5410007A1B2C3D4E5F60718 /* RestorableAgentSessionIndexTests.swift */, F54100A1A1B2C3D4E5F60718 /* RestorableAgentSessionStalePIDTests.swift */, + A6C400020000000000000001 /* VaultAgentProcessCandidateSelectorTests.swift */, F5300001A1B2C3D4E5F60718 /* RovoDevSessionIndexTests.swift */, 3865B0043865B0043865B004 /* SearchIndexTests.swift */, F5300003A1B2C3D4E5F60718 /* RovoDevTranscriptPreviewTests.swift */, @@ -5342,6 +5404,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */, B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, + 7863B0010000000000000001 /* BrowserPanelFocusAndAppearanceTests.swift */, C42660040000000000000002 /* BrowserPDFPreviewActionRegressionTests.swift */, C42660050000000000000002 /* BrowserPDFPreviewActionUnitTests.swift */, C0DE78950000000000000002 /* BrowserPortalFirstRevealScrollTests.swift */, @@ -5354,6 +5417,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D3052001000000000000002 /* TerminalSurfaceResizePolicyTests.swift */, EC010C02 /* TerminalUploadCommandTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + 7863B0020000000000000001 /* GhosttyBackgroundAndPanelAppearanceTests.swift */, A11E5E1E0000000000000012 /* TerminalSelectionAccessibilityIngressGateTests.swift */, 570ED364A7E41085FB2D31FE /* MainWindowSelfSizingTests.swift */, 43DB195131CD53CCCCEEEB79 /* TerminalWindowPortalLifecycleHiddenRefreshTests.swift */, @@ -5475,6 +5539,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */, + A11CE0020000000000000002 /* MobileWorkspaceObserverMetricsTests.swift */, + B17C00010000000000000002 /* InvalidationBatchingBehaviorTests.swift */, 645645000000000000000001 /* NumberedShortcutSwapTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, @@ -5483,6 +5549,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */, D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */, 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */, + C0DE35870000000000000002 /* BackgroundWorkspacePrimeCoordinatorTests.swift */, C0DE64160000000000000002 /* TabManagerNotificationFocusRegressionTests.swift */, C06552030000000000000002 /* TabManagerTitleUpdateStalenessTests.swift */, C06552020000000000000002 /* TabManagerTitleUpdateTests.swift */, @@ -5565,6 +5632,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D78976000000000000000002 /* GhosttyCurrentDirectoryActionDispatcherTests.swift */, C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */, + C7A513000000000000000001 /* CmuxTopProcessSnapshotStoreTests.swift */, + 7863B0030000000000000001 /* PortScanSnapshotStoreTests.swift */, + 7863B0040000000000000001 /* PortScannerSharedSnapshotTests.swift */, + 7863B0050000000000000001 /* ProcessPerformanceMetricsEpochTests.swift */, + 7863B0060000000000000001 /* ProcessSnapshotTestSupport.swift */, + C7AC31000000000000000001 /* ProcessSnapshotCentralizationTests.swift */, C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, C7A50C000000000000000004 /* CmuxTopProcessArgumentsTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, @@ -5632,7 +5705,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 0A0F00580000000000000004 /* CampfireSupportTestSupport.swift */, A5E01204A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, - 481E6848F8513F27E496F7BE /* TabManagerBackgroundWorkspaceMountBoundTests.swift */, C0DE4300000000000000000A /* CmuxAgentChatConfigTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, C0DEF0A40000000000000002 /* CmuxConfigContextMenuTests.swift */, @@ -6101,6 +6173,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A52D100000000000000002 /* AgentChatSessionRegistry+Lifecycle.swift in Sources */, C7A52D200000000000000002 /* AgentChatSessionRegistry+LiveAgentPID.swift in Sources */, C7A52D000000000000000002 /* AgentChatSessionRegistry+ObserveScan.swift in Sources */, + 7863A0020000000000000002 /* AgentChatSessionRegistry+ProcessClassification.swift in Sources */, ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */, C0DE71A00000000000000001 /* AgentChatThemeSync.swift in Sources */, ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */, @@ -6264,6 +6337,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D0B1002EA1B2C3D4E5F60001 /* BrowserPaneDropRouting.swift in Sources */, D0B1001EA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift in Sources */, D7632001A1B2C3D4E5F60718 /* BrowserPaneFileDropRouting.swift in Sources */, + 7863A0030000000000000002 /* BrowserPanel+AddressBarFocusOwnership.swift in Sources */, C65241010000000000000001 /* BrowserPanel+AppPricingRestore.swift in Sources */, C3711E000000000000000001 /* BrowserPanel+DisplayURL.swift in Sources */, D7632B11A1B2C3D4E5F60718 /* BrowserPanel+InteractiveModalHostWindow.swift in Sources */, @@ -6274,6 +6348,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001402 /* BrowserPanel.swift in Sources */, B1F0C0090000000000000001 /* BrowserPanelDeveloperToolsLifecycle.swift in Sources */, C62530010000000000000001 /* BrowserPanelReloadMode.swift in Sources */, + 7863A0040000000000000002 /* BrowserPanelView+AddressBarFocusRequest.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, D0B1003EA1B2C3D4E5F60001 /* BrowserPaneSplitTarget.swift in Sources */, C67540070000000000000001 /* BrowserPDFDocumentToolbarButtons.swift in Sources */, @@ -6504,6 +6579,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE64020000000000001002 /* FilePreviewQuickLookContainerView.swift in Sources */, DD62AFF1D95A1D5D1850AC10 /* FilePreviewQuickLookSession.swift in Sources */, D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */, + F17EED170000000000000001 /* FilePreviewTextEditorSession.swift in Sources */, F11E52270000000000005227 /* FilePreviewWordWrapSettings.swift in Sources */, A5001445A5001445A5001445 /* FilePreviewWorkspaceOpenSupport.swift in Sources */, F1A0C0DE0000000000000002 /* FindTextFieldSupport.swift in Sources */, @@ -6527,6 +6603,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */, C7261F030000000000000001 /* GhosttyNSView+PointerFocusActivation.swift in Sources */, 265FFB2D782433ED46B87F32 /* GhosttyNSView+RemoteReconnect.swift in Sources */, + 7863A0010000000000000002 /* GhosttyNSView+RendererProfiling.swift in Sources */, C75100010000000000000001 /* GhosttyNSView+SelectionTranslation.swift in Sources */, EC010E01 /* GhosttyNSView+TerminalCustomUpload.swift in Sources */, C54860070000000000000001 /* GhosttyNSViewSurfaceLinkCopy.swift in Sources */, @@ -6642,6 +6719,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = AB57F0C27BECE8EDFC6B2B97 /* MobileTerminalByteTee.swift in Sources */, C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */, 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */, + A11CE0010000000000000001 /* MobileWorkspaceObserverMetrics.swift in Sources */, D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */, C75740020000000000000001 /* MouseDownMenuItemView.swift in Sources */, 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */, @@ -6702,16 +6780,21 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B79500070000000000000001 /* PortScanner+PanelKey.swift in Sources */, B79500010000000000000001 /* PortScanner+Process.swift in Sources */, B79500090000000000000001 /* PortScanner+Publication.swift in Sources */, + 7863A0050000000000000002 /* PortScanner+SnapshotProjection.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, B79500100000000000000001 /* PortScanPublicationBatch.swift in Sources */, B79500080000000000000001 /* PortScanPublicationBuffer.swift in Sources */, B79500060000000000000001 /* PortScanPublicationState.swift in Sources */, + C7A5F1000000000000000002 /* PortScanSnapshotStore.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, D1F0A00100000000000000B1 /* PresenceHeartbeatClient.swift in Sources */, D1F0A00100000000000000B3 /* PresenceSettings.swift in Sources */, C0DEA771000000000000000D /* PricingPlansScreen.swift in Sources */, C0DEA771000000000000000B /* ProBadgeStyle.swift in Sources */, F0ACC0DE000000000000000D /* ProcessDetectedResumeIndexes.swift in Sources */, + C7A5FF000000000000000004 /* ProcessPerformanceCaptureProof.swift in Sources */, + C7A5FF000000000000000006 /* ProcessPerformanceExerciseGate.swift in Sources */, + C7A5FF000000000000000002 /* ProcessPerformanceMetrics.swift in Sources */, D75280010000000000000001 /* ProfilingSignposts.swift in Sources */, A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */, A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */, @@ -6969,7 +7052,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5F100000000000000000005 /* TabItemView+WorkspaceNotificationsMenu.swift in Sources */, A5F100000000000000000005 /* TabItemView+WorkspaceNotificationsMenu.swift in Sources */, 186A0F2EE1C92446E0A2D1E3 /* TabItemView+WorkspaceTodo.swift in Sources */, - 7136B0017136B0017136B001 /* TabManager+BackgroundWorkspaceMountPolicy.swift in Sources */, DCDC1000000000000000B015 /* TabManager+BrowserFocus.swift in Sources */, D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */, E3309A03 /* TabManager+EqualizeSplits.swift in Sources */, @@ -6987,6 +7069,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, 7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */, + C4A100000000000000000001 /* TerminalController+Capabilities.swift in Sources */, C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */, C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */, CA52E0020000000000000000 /* TerminalController+ControlCanvasContext.swift in Sources */, @@ -6997,6 +7080,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */, C0DE00000000000000000C56 /* TerminalController+ControlPaneContext.swift in Sources */, C0DE00000000000000000D84 /* TerminalController+ControlPaneDock.swift in Sources */, + C0DE00000000000000000C7E /* TerminalController+ControlPerformanceContext.swift in Sources */, C0DE00000000000000000C70 /* TerminalController+ControlProjectContext.swift in Sources */, C0DE00000000000000000C72 /* TerminalController+ControlSidebarContext.swift in Sources */, C0DE00000000000000000C74 /* TerminalController+ControlSidebarContext2.swift in Sources */, @@ -7123,6 +7207,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A500120D /* UpdateLogStore.swift in Sources */, A5001208 /* UpdateTitlebarAccessory.swift in Sources */, C2035A070000000000000001 /* URLRequest+BrowserFailedNavigation.swift in Sources */, + A6C400010000000000000002 /* VaultAgentProcessCandidateSelector.swift in Sources */, F76550040000000000000001 /* VaultAgentProcessScanner+CodexSessionCwd.swift in Sources */, F76550010000000000000001 /* VaultAgentProcessScanner+ForkMarkers.swift in Sources */, F0F0CF0C0000000000000001 /* VaultAgentProcessScanner+ForkParentFallback.swift in Sources */, @@ -7431,6 +7516,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7C592156DCB3419BB95383E5 /* AutoNamingEngineTests.swift in Sources */, A4ECF22C62724853A72D6BDE /* AutoNamingGrokAdapterTests.swift in Sources */, 74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */, + C0DE35870000000000000001 /* BackgroundWorkspacePrimeCoordinatorTests.swift in Sources */, D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */, BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */, D7032A030000000000000001 /* BrowserClientCertificateCredentialPickerTests.swift in Sources */, @@ -7454,6 +7540,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B7380002B7380002B7380002 /* BrowserOmnibarSuggestionClickRoutingTests.swift in Sources */, D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */, D7632003A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift in Sources */, + 7863B0010000000000000002 /* BrowserPanelFocusAndAppearanceTests.swift in Sources */, B65060010000000000000002 /* BrowserPanelSessionRestoreTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, C42660040000000000000001 /* BrowserPDFPreviewActionRegressionTests.swift in Sources */, @@ -7540,6 +7627,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */, C7A50C000000000000000003 /* CmuxTopProcessArgumentsTests.swift in Sources */, C7A50C000000000000000002 /* CmuxTopProcessCPUTests.swift in Sources */, + C7A513000000000000000002 /* CmuxTopProcessSnapshotStoreTests.swift in Sources */, C7A5090000000000000005A2 /* CmuxTopSnapshotScopeCacheTests.swift in Sources */, C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */, D7C0DE00000000000000A103 /* CmuxWebViewContextMenuLinkCaptureTests.swift in Sources */, @@ -7597,6 +7685,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F0F0CF0D0000000000000001 /* ForkParentFallbackSessionIndexTests.swift in Sources */, F17F00000000000000000003 /* FullWidthTabCommandTests.swift in Sources */, F17F00000000000000000001 /* FullWidthTabPersistenceTests.swift in Sources */, + 7863B0020000000000000002 /* GhosttyBackgroundAndPanelAppearanceTests.swift in Sources */, 1718C0DE1718C0DE17180002 /* GhosttyCommandShiftForwardingTests.swift in Sources */, A11EB0000000000000000000 /* GhosttyConfigPathResolverTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, @@ -7615,6 +7704,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */, B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */, F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, + B17C00010000000000000001 /* InvalidationBatchingBehaviorTests.swift in Sources */, C34670050000000000000001 /* KeyboardShortcutContextSwiftTests.swift in Sources */, C34670020000000000000001 /* KeyboardShortcutContextTests.swift in Sources */, 6042A0026042A0026042A002 /* KeyboardShortcutModifierHoldHintsSettingsFileTests.swift in Sources */, @@ -7645,6 +7735,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE73840000000000000001 /* MobileHostWorkspaceTicketAuthorizationTests.swift in Sources */, 9B08F916D7FF7626C6820727 /* MobilePairingConnectionTransitionTests.swift in Sources */, F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */, + A11CE0020000000000000001 /* MobileWorkspaceObserverMetricsTests.swift in Sources */, 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */, 596100000000000000000001 /* NativeNotificationFallbackCommandTests.swift in Sources */, A5FB1400 /* NewWorkspaceMenuModelTests.swift in Sources */, @@ -7672,10 +7763,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */, B79500320000000000000001 /* PortScannerIdentityContinuityTests.swift in Sources */, B79500300000000000000001 /* PortScannerPublicationTests.swift in Sources */, + 7863B0040000000000000002 /* PortScannerSharedSnapshotTests.swift in Sources */, F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */, + 7863B0030000000000000002 /* PortScanSnapshotStoreTests.swift in Sources */, B8E9500A0000000000000001 /* PostHogAnalyticsPropertiesTests.swift in Sources */, DE71CE00000000000000000A /* PresenceHeartbeatClientTests.swift in Sources */, + 7863B0050000000000000002 /* ProcessPerformanceMetricsEpochTests.swift in Sources */, C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */, + C7AC31000000000000000002 /* ProcessSnapshotCentralizationTests.swift in Sources */, + 7863B0060000000000000002 /* ProcessSnapshotTestSupport.swift in Sources */, A64610020000000000000001 /* QuitConfirmationAlertPresenterTests.swift in Sources */, A6D1F0400000000000000002 /* ReleasingWindowControllerTests.swift in Sources */, 791100030000000000000001 /* RemoteDisconnectLifecycleTests.swift in Sources */, @@ -7795,7 +7891,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F6572012A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift in Sources */, A7206B010000000000000001 /* SystemAppearanceObserverTests.swift in Sources */, FBE660ABA1854983B84368C6 /* SystemWideHotkeyShortcutPolicyTests.swift in Sources */, - F8120F5386FBE726864D340B /* TabManagerBackgroundWorkspaceMountBoundTests.swift in Sources */, C0DE64160000000000000001 /* TabManagerNotificationFocusRegressionTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, C06552030000000000000001 /* TabManagerTitleUpdateStalenessTests.swift in Sources */, @@ -7834,6 +7929,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E30760060000000000000002 /* TmuxWorkspacePaneOverlayModelTests.swift in Sources */, D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + A6C400020000000000000002 /* VaultAgentProcessCandidateSelectorTests.swift in Sources */, A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */, C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */, C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */, diff --git a/cmuxTests/BackgroundWorkspacePrimeCoordinatorTests.swift b/cmuxTests/BackgroundWorkspacePrimeCoordinatorTests.swift new file mode 100644 index 000000000000..5c610d89d040 --- /dev/null +++ b/cmuxTests/BackgroundWorkspacePrimeCoordinatorTests.swift @@ -0,0 +1,170 @@ +import Combine +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct BackgroundWorkspacePrimeCoordinatorTests { + @Test + func timeoutAdvancesSequentiallyAndCancellationStopsThePass() async { + let first = UUID() + let second = UUID() + let timeout = ManualPrimeTimeout() + let host = FakeBackgroundWorkspacePrimeHost( + pendingWorkspaceIDs: [first, second], + states: [first: .needsSurfaceStart, second: .needsSurfaceStart] + ) + let coordinator = BackgroundWorkspacePrimeCoordinator(timeoutSleep: timeout.sleep) + + let task = Task { + await coordinator.primePendingBackgroundWorkspaces(host: host) + } + await host.waitForRequestCount(1, workspaceID: first) + + timeout.fire() + await host.waitForRequestCount(1, workspaceID: second) + + task.cancel() + await task.value + + #expect(host.pendingBackgroundWorkspaceLoadIds == [first, second]) + } + + @Test + func readinessCompletionClearsPendingState() async { + let workspaceID = UUID() + let host = FakeBackgroundWorkspacePrimeHost( + pendingWorkspaceIDs: [workspaceID], + states: [workspaceID: .needsSurfaceStart] + ) + host.stateAfterRequest[workspaceID] = .ready + let coordinator = BackgroundWorkspacePrimeCoordinator() + + await coordinator.primePendingBackgroundWorkspaces(host: host) + + #expect(host.requestCount(for: workspaceID) == 1) + #expect(host.pendingBackgroundWorkspaceLoadIds.isEmpty) + } + + @Test + func removedWorkspaceClearsPendingState() async { + let workspaceID = UUID() + let host = FakeBackgroundWorkspacePrimeHost( + pendingWorkspaceIDs: [workspaceID], + states: [workspaceID: .workspaceRemoved] + ) + let coordinator = BackgroundWorkspacePrimeCoordinator() + + await coordinator.primePendingBackgroundWorkspaces(host: host) + + #expect(host.pendingBackgroundWorkspaceLoadIds.isEmpty) + } +} + +private final class ManualPrimeTimeout: @unchecked Sendable { + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + let pair = AsyncStream.makeStream() + stream = pair.stream + continuation = pair.continuation + } + + func sleep() async throws { + for await _ in stream { + try Task.checkCancellation() + return + } + throw CancellationError() + } + + func fire() { + continuation.yield(()) + } +} + +@MainActor +private final class FakeBackgroundWorkspacePrimeHost: BackgroundWorkspacePrimeHosting { + private struct RequestWaiter { + let workspaceID: UUID + let count: Int + let continuation: CheckedContinuation + } + + private let pendingSubject: CurrentValueSubject, Never> + private let workspaceIDsSubject: CurrentValueSubject, Never> + private var requestCounts: [UUID: Int] = [:] + private var requestWaiters: [RequestWaiter] = [] + + var states: [UUID: BackgroundWorkspacePrimeWorkState] + var stateAfterRequest: [UUID: BackgroundWorkspacePrimeWorkState] = [:] + + var pendingBackgroundWorkspaceLoadIds: Set { pendingSubject.value } + + var backgroundWorkspacePrimePendingPublisher: AnyPublisher, Never> { + pendingSubject.eraseToAnyPublisher() + } + + var backgroundWorkspacePrimeWorkspaceIDsPublisher: AnyPublisher, Never> { + workspaceIDsSubject.eraseToAnyPublisher() + } + + init( + pendingWorkspaceIDs: Set, + states: [UUID: BackgroundWorkspacePrimeWorkState] + ) { + pendingSubject = CurrentValueSubject(pendingWorkspaceIDs) + workspaceIDsSubject = CurrentValueSubject(Set(states.keys)) + self.states = states + } + + func backgroundWorkspacePrimeWorkState(for workspaceID: UUID) -> BackgroundWorkspacePrimeWorkState { + states[workspaceID] ?? .workspaceRemoved + } + + func requestBackgroundWorkspacePrimeSurfaceStart(for workspaceID: UUID) { + requestCounts[workspaceID, default: 0] += 1 + if let nextState = stateAfterRequest[workspaceID] { + states[workspaceID] = nextState + } + resumeSatisfiedRequestWaiters() + } + + func completeBackgroundWorkspaceLoad(for workspaceID: UUID) { + var pending = pendingSubject.value + pending.remove(workspaceID) + pendingSubject.send(pending) + } + + func requestCount(for workspaceID: UUID) -> Int { + requestCounts[workspaceID, default: 0] + } + + func waitForRequestCount(_ count: Int, workspaceID: UUID) async { + guard requestCount(for: workspaceID) < count else { return } + await withCheckedContinuation { continuation in + requestWaiters.append( + RequestWaiter(workspaceID: workspaceID, count: count, continuation: continuation) + ) + } + } + + private func resumeSatisfiedRequestWaiters() { + var remaining: [RequestWaiter] = [] + for waiter in requestWaiters { + if requestCount(for: waiter.workspaceID) >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + requestWaiters = remaining + } +} diff --git a/cmuxTests/BrowserPanelFocusAndAppearanceTests.swift b/cmuxTests/BrowserPanelFocusAndAppearanceTests.swift new file mode 100644 index 000000000000..7d193bcc83a3 --- /dev/null +++ b/cmuxTests/BrowserPanelFocusAndAppearanceTests.swift @@ -0,0 +1,326 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications +import Darwin +import Testing +import CmuxBrowser + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +private func drainBrowserPanelMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) +} + +@MainActor +private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) +} + +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + } + + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + } + + func testTransparentGhosttyBackgroundUsesCompositedOmnibarPill() { + let baseColor = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let themeBackground = GhosttyBackgroundTheme.color(backgroundColor: baseColor, opacity: 0.42) + + guard let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: .light, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(actual.alphaComponent, 1.0, accuracy: 0.001) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + darkenMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} +@MainActor +final class BrowserPanelProfileIsolationTests: XCTestCase { + func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { + let alternateProfile = try makeTemporaryBrowserPanelProfile(named: "Switched") + let defaultStore = BrowserHistoryStore.shared + let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) + defaultStore.clearHistory() + alternateStore.clearHistory() + defer { + defaultStore.clearHistory() + alternateStore.clearHistory() + } + + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID + ) + let staleWebView = panel.webView + let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) + let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) + staleWebView.loadHTMLString( + "Stalestale", + baseURL: staleURL + ) + + XCTAssertTrue( + panel.switchToProfile(alternateProfile.id), + "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + ) + defaultStore.clearHistory() + alternateStore.clearHistory() + + staleDelegate.webView?(staleWebView, didFinish: nil) + drainBrowserPanelMainQueue() + + XCTAssertTrue( + defaultStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" + ) + XCTAssertTrue( + alternateStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" + ) + } +} +@MainActor +final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { + func testRequestPersistsUntilAcknowledged() { + let panel = BrowserPanel(workspaceId: UUID()) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + + let requestId = panel.requestAddressBarFocus() + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId) + XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + + panel.acknowledgeAddressBarFocusRequest(requestId) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) + + // Without a mounted address-bar view holding its own focus lease, consuming + // the durable request must not leave WebKit focus suppression latched. + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testRequestCoalescesWhilePending() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus(selectionIntent: .preserveFieldEditorSelection) + let secondRequest = panel.requestAddressBarFocus() + + XCTAssertEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest) + XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) + } + + func testExplicitSelectAllRequestUpgradesPendingPreserveRequest() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus(selectionIntent: .preserveFieldEditorSelection) + let secondRequest = panel.requestAddressBarFocus(selectionIntent: .selectAll) + + XCTAssertNotEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .selectAll) + } + + func testStaleAcknowledgementDoesNotClearNewestRequest() { + let panel = BrowserPanel(workspaceId: UUID()) + let firstRequest = panel.requestAddressBarFocus() + panel.acknowledgeAddressBarFocusRequest(firstRequest) + let secondRequest = panel.requestAddressBarFocus() + + XCTAssertNotEqual(firstRequest, secondRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + + panel.acknowledgeAddressBarFocusRequest(firstRequest) + XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) + + panel.acknowledgeAddressBarFocusRequest(secondRequest) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + } + + func testMountedViewLeaseKeepsSuppressionAfterRequestAcknowledgement() { + let panel = BrowserPanel(workspaceId: UUID()) + let owner = UUID() + let requestId = panel.requestAddressBarFocus() + + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: owner, reason: "test.mount") + ) + panel.acknowledgeAddressBarFocusRequest(requestId) + + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + XCTAssertTrue( + panel.relinquishAddressBarViewFocusLease(owner: owner, reason: "test.unmount") + ) + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testStaleViewCannotReleaseReplacementViewFocusLease() { + let panel = BrowserPanel(workspaceId: UUID()) + let staleOwner = UUID() + let replacementOwner = UUID() + + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: staleOwner, reason: "test.stale.mount") + ) + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: replacementOwner, reason: "test.replacement.mount") + ) + XCTAssertTrue( + panel.relinquishAddressBarViewFocusLease(owner: staleOwner, reason: "test.stale.unmount") + ) + + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + XCTAssertTrue( + panel.relinquishAddressBarViewFocusLease(owner: replacementOwner, reason: "test.replacement.unmount") + ) + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testPendingRequestKeepsSuppressionWhileFocusedViewRemounts() { + let panel = BrowserPanel(workspaceId: UUID()) + let owner = UUID() + let requestId = panel.requestAddressBarFocus() + + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: owner, reason: "test.mount") + ) + XCTAssertTrue( + panel.relinquishAddressBarViewFocusLease(owner: owner, reason: "test.unmount") + ) + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + + panel.acknowledgeAddressBarFocusRequest(requestId) + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testNewestPresentationOwnerSurvivesStaleReregistrationAndUnregister() { + let panel = BrowserPanel(workspaceId: UUID()) + let staleOwner = UUID() + let replacementOwner = UUID() + + XCTAssertTrue(panel.registerAddressBarViewPresentation(owner: staleOwner)) + XCTAssertTrue(panel.registerAddressBarViewPresentation(owner: replacementOwner)) + XCTAssertEqual(panel.currentAddressBarViewPresentationOwner, replacementOwner) + + XCTAssertFalse(panel.registerAddressBarViewPresentation(owner: staleOwner)) + XCTAssertEqual(panel.currentAddressBarViewPresentationOwner, replacementOwner) + + XCTAssertTrue(panel.unregisterAddressBarViewPresentation(owner: staleOwner)) + XCTAssertEqual(panel.currentAddressBarViewPresentationOwner, replacementOwner) + } + + func testRemovingNewestPresentationPromotesSurvivingOwner() { + let panel = BrowserPanel(workspaceId: UUID()) + let previousOwner = UUID() + let newestOwner = UUID() + + XCTAssertTrue(panel.registerAddressBarViewPresentation(owner: previousOwner)) + XCTAssertTrue(panel.registerAddressBarViewPresentation(owner: newestOwner)) + XCTAssertTrue(panel.unregisterAddressBarViewPresentation(owner: newestOwner)) + + XCTAssertEqual(panel.currentAddressBarViewPresentationOwner, previousOwner) + XCTAssertTrue(panel.unregisterAddressBarViewPresentation(owner: previousOwner)) + XCTAssertNil(panel.currentAddressBarViewPresentationOwner) + } + + func testFindTransitionDoesNotRestoreReleasedAddressBarSuppression() { + let panel = BrowserPanel(workspaceId: UUID()) + let owner = UUID() + + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: owner, reason: "test.mount") + ) + panel.startFind() + XCTAssertTrue(panel.shouldSuppressWebViewFocus()) + + panel.hideFind() + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + } + + func testWebViewHandoffRevokesEveryOverlappingViewLease() { + let panel = BrowserPanel(workspaceId: UUID()) + let staleOwner = UUID() + let activeOwner = UUID() + let panelId = panel.id + let blurNotification = expectation(description: "panel-wide address bar blur") + let observer = NotificationCenter.default.addObserver( + forName: .browserDidBlurAddressBar, + object: nil, + queue: .main + ) { notification in + guard notification.object as? UUID == panelId else { return } + blurNotification.fulfill() + } + defer { NotificationCenter.default.removeObserver(observer) } + + _ = panel.requestAddressBarFocus(selectionIntent: .selectAll) + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: staleOwner, reason: "test.stale.mount") + ) + XCTAssertTrue( + panel.acquireAddressBarViewFocusLease(owner: activeOwner, reason: "test.active.mount") + ) + + panel.endAddressBarFocusForWebViewHandoff(reason: "test.escape") + + wait(for: [blurNotification], timeout: 1) + XCTAssertNil(panel.pendingAddressBarFocusRequestId) + XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) + XCTAssertFalse(panel.shouldSuppressWebViewFocus()) + XCTAssertFalse( + panel.relinquishAddressBarViewFocusLease(owner: staleOwner, reason: "test.stale.unmount") + ) + XCTAssertFalse( + panel.relinquishAddressBarViewFocusLease(owner: activeOwner, reason: "test.active.unmount") + ) + } +} diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 8bbdf05b18fc..925c58bb3635 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -16,14 +16,6 @@ import CmuxBrowser @testable import cmux #endif -private func drainBrowserPanelMainQueue() { - let expectation = XCTestExpectation(description: "drain main queue") - DispatchQueue.main.async { - expectation.fulfill() - } - XCTWaiter().wait(for: [expectation], timeout: 1.0) -} - private final class BrowserPanelTestNavigationDelegate: NSObject, WKNavigationDelegate { let expectation: XCTestExpectation var error: Error? @@ -339,15 +331,6 @@ final class BrowserPanelVisualAutomationRestoreHostTests: XCTestCase { } } -@MainActor -private func makeTemporaryBrowserPanelProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) -} - final class BrowserPanelChromeBackgroundColorTests: XCTestCase { func testLightModeUsesThemeBackgroundColor() { assertResolvedColorMatchesTheme(for: .light) @@ -843,167 +826,6 @@ final class BrowserPanelDiffViewerSchemeTests: XCTestCase { } -final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { - func testLightModeSlightlyDarkensThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) - } - - func testDarkModeSlightlyDarkensThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) - } - - func testTransparentGhosttyBackgroundUsesCompositedOmnibarPill() { - let baseColor = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) - let themeBackground = GhosttyBackgroundTheme.color(backgroundColor: baseColor, opacity: 0.42) - - guard let actual = resolvedBrowserOmnibarPillBackgroundColor( - for: .light, - themeBackgroundColor: themeBackground - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(actual.alphaComponent, 1.0, accuracy: 0.001) - } - - private func assertResolvedColorMatchesExpectedBlend( - for colorScheme: ColorScheme, - darkenMix: CGFloat, - file: StaticString = #filePath, - line: UInt = #line - ) { - let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) - let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground - - guard - let actual = resolvedBrowserOmnibarPillBackgroundColor( - for: colorScheme, - themeBackgroundColor: themeBackground - ).usingColorSpace(.sRGB), - let expectedSRGB = expected.usingColorSpace(.sRGB), - let themeSRGB = themeBackground.usingColorSpace(.sRGB) - else { - XCTFail("Expected sRGB-convertible colors", file: file, line: line) - return - } - - XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) - XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) - XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) - } -} - - -@MainActor -final class BrowserPanelProfileIsolationTests: XCTestCase { - func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { - let alternateProfile = try makeTemporaryBrowserPanelProfile(named: "Switched") - let defaultStore = BrowserHistoryStore.shared - let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) - defaultStore.clearHistory() - alternateStore.clearHistory() - defer { - defaultStore.clearHistory() - alternateStore.clearHistory() - } - - let panel = BrowserPanel( - workspaceId: UUID(), - profileID: BrowserProfileStore.shared.builtInDefaultProfileID - ) - let staleWebView = panel.webView - let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) - let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) - staleWebView.loadHTMLString( - "Stalestale", - baseURL: staleURL - ) - - XCTAssertTrue( - panel.switchToProfile(alternateProfile.id), - "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" - ) - defaultStore.clearHistory() - alternateStore.clearHistory() - - staleDelegate.webView?(staleWebView, didFinish: nil) - drainBrowserPanelMainQueue() - - XCTAssertTrue( - defaultStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" - ) - XCTAssertTrue( - alternateStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" - ) - } -} - - -@MainActor -final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { - func testRequestPersistsUntilAcknowledged() { - let panel = BrowserPanel(workspaceId: UUID()) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) - - let requestId = panel.requestAddressBarFocus() - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId) - XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) - XCTAssertTrue(panel.shouldSuppressWebViewFocus()) - - panel.acknowledgeAddressBarFocusRequest(requestId) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) - XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) - - // Acknowledgement only clears the durable request; focus suppression follows - // explicit blur state transitions. - XCTAssertTrue(panel.shouldSuppressWebViewFocus()) - panel.endSuppressWebViewFocusForAddressBar() - XCTAssertFalse(panel.shouldSuppressWebViewFocus()) - } - - func testRequestCoalescesWhilePending() { - let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus(selectionIntent: .preserveFieldEditorSelection) - let secondRequest = panel.requestAddressBarFocus() - - XCTAssertEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest) - XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .preserveFieldEditorSelection) - } - - func testExplicitSelectAllRequestUpgradesPendingPreserveRequest() { - let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus(selectionIntent: .preserveFieldEditorSelection) - let secondRequest = panel.requestAddressBarFocus(selectionIntent: .selectAll) - - XCTAssertNotEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusSelectionIntent, .selectAll) - } - - func testStaleAcknowledgementDoesNotClearNewestRequest() { - let panel = BrowserPanel(workspaceId: UUID()) - let firstRequest = panel.requestAddressBarFocus() - panel.acknowledgeAddressBarFocusRequest(firstRequest) - let secondRequest = panel.requestAddressBarFocus() - - XCTAssertNotEqual(firstRequest, secondRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) - - panel.acknowledgeAddressBarFocusRequest(firstRequest) - XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest) - - panel.acknowledgeAddressBarFocusRequest(secondRequest) - XCTAssertNil(panel.pendingAddressBarFocusRequestId) - } -} - - @MainActor final class BrowserPanelReactGrabBridgeTests: XCTestCase { @MainActor @@ -2914,6 +2736,14 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { RunLoop.current.run(until: Date().addingTimeInterval(0.25)) } + private func drainMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) + } + private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { let candidates = slot.subviews + (slot.superview?.subviews ?? []) return candidates.first(where: { diff --git a/cmuxTests/CmuxEventBusTests.swift b/cmuxTests/CmuxEventBusTests.swift index 3cc55f2b613f..99c5f875c812 100644 --- a/cmuxTests/CmuxEventBusTests.swift +++ b/cmuxTests/CmuxEventBusTests.swift @@ -1,6 +1,7 @@ import XCTest import CMUXAgentLaunch import Darwin +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -594,3 +595,204 @@ final class CmuxEventBusTests: XCTestCase { return try XCTUnwrap(size).uint64Value } } + +@Suite("Cached cmux event frames") +struct CmuxEventFrameCacheSwiftTests { + private final class CodecCounter { + var sanitizations = 0 + var encodings = 0 + } + + @Test func boundedRingBufferClearsWrappedElementsAndReusesStorage() { + var buffer = CmuxBoundedRingBuffer(capacity: 3) + buffer.appendDroppingOldest(1) + buffer.appendDroppingOldest(2) + buffer.appendDroppingOldest(3) + #expect(buffer.removeFirst() == 1) + buffer.appendDroppingOldest(4) + #expect(buffer.elements == [2, 3, 4]) + + buffer.removeAll() + #expect(buffer.isEmpty) + #expect(buffer.elements.isEmpty) + + buffer.appendDroppingOldest(5) + buffer.appendDroppingOldest(6) + #expect(buffer.elements == [5, 6]) + } + + @Test func onePublishSanitizesAndEncodesOnceAcrossSixteenSubscribers() throws { + let counter = CodecCounter() + let bus = CmuxEventBus( + retainedEventLimit: 8, + sanitize: { value in + counter.sanitizations += 1 + return CmuxEventBus.sanitizedJSONValue(value) + }, + encodeSanitizedLine: { object in + counter.encodings += 1 + return CmuxEventBus.encodeSanitizedLine(object) + } + ) + let snapshots = (0..<16).map { _ in + bus.subscribe(afterSequence: nil, names: [], categories: []) + } + defer { snapshots.forEach { bus.unsubscribe($0.subscription) } } + + bus.publish( + name: "agent.hook.PreToolUse", + category: "agent", + source: "test", + payload: ["nested": ["value": UUID()]] + ) + + let frames = try snapshots.map { + try #require($0.subscription.nextFrame(timeout: 0.1)) + } + #expect(counter.sanitizations == 1) + #expect(counter.encodings == 1) + #expect(frames.dropFirst().allSatisfy { $0 === frames[0] }) + } + + @Test func liveReplayAndDurableLogReuseIdenticalPrivateBytes() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-event-frame-cache-\(UUID().uuidString)", isDirectory: true) + let logURL = directory.appendingPathComponent("events.jsonl") + defer { try? FileManager.default.removeItem(at: directory) } + + let bus = CmuxEventBus(retainedEventLimit: 8, eventLogURL: logURL) + let live = bus.subscribe(afterSequence: nil, names: [], categories: ["agent"]) + defer { bus.unsubscribe(live.subscription) } + let workstream = WorkstreamEvent( + sessionId: "session", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace", + cwd: "/tmp/workspace", + toolName: "Bash", + toolInputJSON: #"{"command":"echo secret-tool-input"}"#, + context: WorkstreamContext( + lastUserMessage: "secret-user-message", + assistantPreamble: "secret-assistant-message" + ), + requestId: "request", + ppid: 42, + receivedAt: Date(timeIntervalSince1970: 0), + extraFieldsJSON: #"{"message":"secret-extra-field"}"# + ) + + bus.publish( + name: "agent.hook.PreToolUse", + category: "agent", + source: "test", + payload: CmuxEventBus.workstreamPayload(workstream) + ) + + let liveFrame = try #require(live.subscription.nextFrame(timeout: 0.1)) + let replay = bus.subscribe(afterSequence: 0, names: [], categories: ["agent"]) + defer { bus.unsubscribe(replay.subscription) } + let replayFrame = try #require(replay.replayFrames.first) + bus.flushEventLogForTesting() + let logLine = try #require( + try String(contentsOf: logURL, encoding: .utf8).split(separator: "\n").first.map(String.init) + ) + + #expect(liveFrame === replayFrame) + #expect(logLine == liveFrame.encodedLine) + #expect(liveFrame.wireData == Data((liveFrame.encodedLine + "\n").utf8)) + #expect(!liveFrame.encodedLine.contains("secret")) + } + + @Test func concurrentPublishersPreserveSequenceOrderForLiveAndReplay() async throws { + let eventCount = 200 + let bus = CmuxEventBus( + retainedEventLimit: eventCount, + maxPendingEventsPerSubscription: eventCount + ) + let live = bus.subscribe(afterSequence: nil, names: [], categories: []) + defer { bus.unsubscribe(live.subscription) } + + await withTaskGroup(of: Void.self) { group in + for index in 0..= 3 { break } + await Task.yield() + } + + await capturer.releaseAll() + let refreshedSnapshot = await refreshed.value + let joinedSnapshot = await joined.value + let cachedSnapshot = await store.snapshot(requirements: .basic, maximumAge: 10) + _ = await first.value + _ = await second.value + + #expect(refreshedSnapshot === joinedSnapshot) + #expect(cachedSnapshot === refreshedSnapshot) + #expect(await capturer.callCount() == 2) + #expect(await capturer.maximumConcurrentCaptures() == 1) + } + + @Test + func cacheReuseFromBeforeResetDoesNotEnterNewMetricsEpoch() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledProcessSnapshotCapturer(autoRelease: true) + let clock = ProcessSnapshotTestClock(now: Date(timeIntervalSince1970: 100)) + let store = CmuxTopProcessSnapshotStore( + now: { await clock.read() }, + capture: { requirements in + await capturer.capture(requirements: requirements) + }, + metrics: metricsStore + ) + + let captured = await store.snapshot( + requirements: .basic, + maximumAge: 10, + consumer: .portScannerPanel + ) + metricsStore.reset() + let reused = await store.snapshot( + requirements: .basic, + maximumAge: 10, + consumer: .portScannerPanel + ) + + let metrics = metricsStore.snapshot() + #expect(captured === reused) + #expect(metrics.processSnapshots.captureStarted == 0) + #expect(metrics.processSnapshots.captureCompleted == 0) + #expect(metrics.consumerGenerationReuse.isEmpty) + } + + @Test + func inFlightReuseFromBeforeResetDoesNotEnterNewMetricsEpoch() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledProcessSnapshotCapturer() + let clock = ProcessSnapshotTestClock(now: Date(timeIntervalSince1970: 100)) + let store = CmuxTopProcessSnapshotStore( + now: { await clock.read() }, + capture: { requirements in + await capturer.capture(requirements: requirements) + }, + metrics: metricsStore + ) + + let first = Task { + await store.snapshot( + requirements: .basic, + maximumAge: 10, + consumer: .portScannerPanel + ) + } + await capturer.waitForCallCount(1) + metricsStore.reset() + let reused = Task { + await store.snapshot( + requirements: .basic, + maximumAge: 10, + consumer: .portScannerPanel + ) + } + await capturer.releaseNext() + + let firstSnapshot = await first.value + let reusedSnapshot = await reused.value + #expect(firstSnapshot === reusedSnapshot) + let metrics = metricsStore.snapshot() + #expect(metrics.processSnapshots.captureStarted == 0) + #expect(metrics.processSnapshots.captureCompleted == 0) + #expect(metrics.consumerGenerationReuse.isEmpty) + } + + @Test + func performanceExerciseForcesOneFreshGenerationWithTwoNamedConsumers() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledProcessSnapshotCapturer(autoRelease: true) + let store = CmuxTopProcessSnapshotStore( + capture: { requirements in + await capturer.capture(requirements: requirements) + }, + metrics: metricsStore + ) + + _ = await store.snapshot(requirements: .basic, maximumAge: 10) + metricsStore.reset(enable: true) + let exercise = await store.performanceMetricsExercise(requestCount: 4) + let metrics = metricsStore.snapshot() + + #expect(exercise?.generation == 2) + #expect(exercise?.measurementEpoch == metrics.measurementEpoch) + #expect(exercise?.processCount == 1) + #expect(exercise?.proof == .libproc) + #expect(exercise?.sharedSnapshotIdentity == true) + #expect(metrics.processSnapshots.captureStarted == 1) + #expect(metrics.processSnapshots.captureCompleted == 1) + #expect(metrics.generations[2]?.backend == "libproc") + #expect(metrics.generations[2]?.processLaunches == 0) + #expect(metrics.requestCountsByConsumer[ProcessSnapshotConsumer.performanceExercisePrimary.rawValue] == 1) + #expect(metrics.requestCountsByConsumer[ProcessSnapshotConsumer.performanceExerciseSecondary.rawValue] == 3) + #expect( + metrics.consumerGenerationReuse[ProcessSnapshotConsumer.performanceExerciseSecondary.rawValue]?[2]?.inFlight == 3 + ) + #expect(await capturer.callCount() == 2) + } + + @Test + func performanceExerciseRejectsAnEmptyProductionCapture() async { + let metricsStore = ProcessPerformanceMetrics(enabled: false) + metricsStore.reset(enable: true) + let capturer = ControlledProcessSnapshotCapturer( + autoRelease: true, + returnsEmptySnapshot: true + ) + let store = CmuxTopProcessSnapshotStore( + capture: { requirements in + await capturer.capture(requirements: requirements) + }, + metrics: metricsStore + ) + + let exercise = await store.performanceMetricsExercise(requestCount: 2) + + #expect(exercise?.generation == nil) + #expect(await capturer.callCount() == 1) + #expect(metricsStore.snapshot().generations[1]?.processCount == 0) + } + + @Test + func performanceExerciseRejectsMetricsFromAnotherMeasurementEpoch() async { + let metricsStore = ProcessPerformanceMetrics( + enabled: false, + unixMilliseconds: { 1_752_345_678_901 } + ) + metricsStore.reset(enable: true) + let capturer = ControlledProcessSnapshotCapturer() + let store = CmuxTopProcessSnapshotStore( + capture: { requirements in + await capturer.capture(requirements: requirements) + }, + metrics: metricsStore + ) + let exercise = Task { await store.performanceMetricsExercise(requestCount: 2) } + await capturer.waitForCallCount(1) + for _ in 0..<10_000 { + if metricsStore.snapshot().requestCountsByConsumer[ + ProcessSnapshotConsumer.performanceExerciseSecondary.rawValue + ] == 1 { break } + await Task.yield() + } + + metricsStore.reset(enable: true) + await capturer.releaseNext() + let result = await exercise.value + + #expect(result?.generation == nil) + #expect(metricsStore.snapshot().measurementEpoch == 2) + #expect(metricsStore.snapshot().resetAtUnixMilliseconds == 1_752_345_678_901) + #expect(metricsStore.snapshot().generations.isEmpty) + } + + @Test + func captureProofSurfacesSubprocessBackendAndLaunchCount() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let store = CmuxTopProcessSnapshotStore( + captureWithProof: { requirements in + ( + CmuxTopProcessSnapshot( + processes: [], + sampledAt: Date(timeIntervalSince1970: 101), + includesProcessDetails: requirements.contains(.processDetails), + includesCMUXScope: requirements.contains(.cmuxScope) + ), + ProcessPerformanceCaptureProof(backend: .subprocess, processLaunchCount: 1) + ) + }, + metrics: metricsStore + ) + + _ = await store.snapshot(requirements: .basic, maximumAge: 0) + let metrics = metricsStore.snapshot() + + #expect(metrics.generations[1]?.backend == "subprocess") + #expect(metrics.generations[1]?.processLaunches == 1) + } + + @Test + func cancellingPerformanceExerciseUnblocksNormalConsumers() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + metricsStore.reset(enable: true) + let capturer = ControlledProcessSnapshotCapturer() + let store = CmuxTopProcessSnapshotStore( + capture: { requirements in + await capturer.capture(requirements: requirements) + }, + metrics: metricsStore + ) + let exercise = Task { await store.performanceMetricsExercise(requestCount: 3) } + await capturer.waitForCallCount(1) + for _ in 0..<10_000 { + if metricsStore.snapshot().requestCountsByConsumer[ + ProcessSnapshotConsumer.performanceExerciseSecondary.rawValue + ] == 2 { break } + await Task.yield() + } + + exercise.cancel() + let normal = Task { + await store.snapshot( + requirements: .basic, + maximumAge: 0, + consumer: .systemTop + ) + } + await capturer.releaseNext() + + let canceledExercise = await exercise.value + #expect(canceledExercise?.generation == nil) + _ = await normal.value + #expect(metricsStore.snapshot().requestCountsByConsumer[ + ProcessSnapshotConsumer.systemTop.rawValue + ] == 1) + } +} diff --git a/cmuxTests/FilePreviewTextEditorTextKitTests.swift b/cmuxTests/FilePreviewTextEditorTextKitTests.swift index 3ff7d2976f69..4fe5f4088136 100644 --- a/cmuxTests/FilePreviewTextEditorTextKitTests.swift +++ b/cmuxTests/FilePreviewTextEditorTextKitTests.swift @@ -31,6 +31,20 @@ import Testing @MainActor @Suite("File preview editor TextKit backing", .serialized) struct FilePreviewTextEditorTextKitTests { + @Test("panel-owned editor session preserves native selection across remounts") + func panelOwnedSessionPreservesSelectionAcrossRemounts() { + let session = FilePreviewTextEditorSession() + let first = session.editorViews() + first.textView.string = "alpha beta gamma" + first.textView.setSelectedRange(NSRange(location: 6, length: 4)) + + let remounted = session.editorViews() + + #expect(remounted.scrollView === first.scrollView) + #expect(remounted.textView === first.textView) + #expect(remounted.textView.selectedRange() == NSRange(location: 6, length: 4)) + } + @Test("makeFilePreviewTextView is a pure TextKit 1 view (no TextKit 2 selection path)") func editorIsPureTextKit1() { let textView = SavingTextView.makeFilePreviewTextView() diff --git a/cmuxTests/GhosttyBackgroundAndPanelAppearanceTests.swift b/cmuxTests/GhosttyBackgroundAndPanelAppearanceTests.swift new file mode 100644 index 000000000000..a0dc4176226b --- /dev/null +++ b/cmuxTests/GhosttyBackgroundAndPanelAppearanceTests.swift @@ -0,0 +1,159 @@ +@preconcurrency import XCTest +import CmuxTerminal +import Testing +import CmuxControlSocket +import CmuxFoundation +import CmuxTerminalCore +import CmuxSettings +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import CMUXMobileCore +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class GhosttyBackgroundThemeTests: XCTestCase { + func testColorClampsOpacity() { + let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) + + let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0) + XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001) + + let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0) + XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001) + } + + func testColorFromNotificationUsesBackgroundAndOpacity() { + let fallbackColor = NSColor.black + let fallbackOpacity = 1.0 + let notification = Notification( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005) + } + + func testColorFromNotificationFallsBackWhenPayloadMissing() { + let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0) + let fallbackOpacity = 0.42 + let notification = Notification(name: .ghosttyDefaultBackgroundDidChange) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005) + } +} +final class PanelAppearanceBackgroundTests: XCTestCase { + func testTransparentGhosttyOpacityUsesClearContentBackground() { + var config = GhosttyConfig() + config.backgroundColor = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) + config.backgroundOpacity = 0.42 + config.backgroundBlur = .disabled + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertTrue(appearance.usesClearContentBackground) + XCTAssertFalse(appearance.drawsContentBackground) + XCTAssertEqual(appearance.backgroundColor.alphaComponent, 0.42, accuracy: 0.0001) + XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 0.0, accuracy: 0.0001) + } + + func testOpaqueGhosttyBackgroundKeepsPanelFill() { + var config = GhosttyConfig() + config.backgroundColor = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) + config.backgroundOpacity = 1.0 + config.backgroundBlur = .disabled + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertFalse(appearance.usesClearContentBackground) + XCTAssertTrue(appearance.drawsContentBackground) + XCTAssertEqual(appearance.backgroundColor.alphaComponent, 1.0, accuracy: 0.0001) + XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 1.0, accuracy: 0.0001) + } + + func testLowContrastPanelForegroundFallsBackToReadableColor() { + var config = GhosttyConfig() + config.backgroundColor = NSColor(hex: "#FFFFFF")! + config.backgroundOpacity = 1.0 + config.foregroundColor = NSColor(hex: "#FFFFFF")! + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertEqual(appearance.foregroundColor.hexString(), "#000000") + } + + func testReadablePanelForegroundPreservesThemeColor() { + var config = GhosttyConfig() + config.backgroundColor = NSColor(hex: "#000000")! + config.backgroundOpacity = 1.0 + config.foregroundColor = NSColor(hex: "#FDF6E3")! + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertEqual(appearance.foregroundColor.hexString(), "#FDF6E3") + } + + func testGhosttyGlassBackgroundUsesClearContentBackground() { + var config = GhosttyConfig() + config.backgroundOpacity = 1.0 + config.backgroundBlur = .macosGlassRegular + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertTrue(appearance.usesClearContentBackground) + XCTAssertFalse(appearance.drawsContentBackground) + XCTAssertEqual(appearance.backgroundColor.alphaComponent, 1.0, accuracy: 0.0001) + XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 0.0, accuracy: 0.0001) + } + + func testTransparentWindowSettingUsesClearContentBackground() { + var config = GhosttyConfig() + config.backgroundOpacity = 1.0 + config.backgroundBlur = .disabled + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: true) + + XCTAssertTrue(appearance.usesClearContentBackground) + XCTAssertFalse(appearance.drawsContentBackground) + XCTAssertEqual(appearance.backgroundColor.alphaComponent, 1.0, accuracy: 0.0001) + XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 0.0, accuracy: 0.0001) + } +} diff --git a/cmuxTests/InvalidationBatchingBehaviorTests.swift b/cmuxTests/InvalidationBatchingBehaviorTests.swift new file mode 100644 index 000000000000..e50c36bcda72 --- /dev/null +++ b/cmuxTests/InvalidationBatchingBehaviorTests.swift @@ -0,0 +1,206 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct InvalidationBatchingBehaviorTests { + @Test func focusNotificationsCoalesceAtQuietDeadline() { + let center = NotificationCenter() + let scheduler = VirtualInvalidationDeadlineScheduler() + let invalidator = FocusHistoryMenuInvalidator( + center: center, + scheduler: scheduler.schedule + ) + + for _ in 0..<99 { + center.post(name: .tabManagerFocusHistoryRevisionDidChange, object: nil) + } + center.post(name: NSWindow.didBecomeKeyNotification, object: nil) + + #expect(invalidator.revision == 0) + scheduler.advance(by: 0.039) + #expect(invalidator.revision == 0) + scheduler.advance(by: 0.002) + #expect(invalidator.revision == 1) + #expect(scheduler.activeDeadlineCount == 0) + } + + @Test func sustainedFocusNotificationsDrainAtMaximumDeadline() { + let center = NotificationCenter() + let scheduler = VirtualInvalidationDeadlineScheduler() + let invalidator = FocusHistoryMenuInvalidator( + center: center, + scheduler: scheduler.schedule + ) + + center.post(name: .tabManagerFocusHistoryRevisionDidChange, object: nil) + for _ in 0..<3 { + scheduler.advance(by: 0.03) + center.post(name: .tabManagerFocusHistoryRevisionDidChange, object: nil) + } + scheduler.advance(by: 0.029) + center.post(name: NSWindow.didBecomeKeyNotification, object: nil) + + #expect(invalidator.revision == 0) + scheduler.advance(by: 0.002) + #expect(invalidator.revision == 1) + #expect(scheduler.activeDeadlineCount == 0) + } + + @Test func mobileWorkspaceBurstDrainsOnceWithFinalStateAtQuietDeadline() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.selectedWorkspace) + let scheduler = VirtualInvalidationDeadlineScheduler() + var emittedTitles: [String] = [] + let observer = MobileWorkspaceListObserver( + tabManager: manager, + scheduler: scheduler.schedule, + emitWorkspaceUpdated: { emittedTitles.append(workspace.title) } + ) + defer { withExtendedLifetime(observer) {} } + + settleInitialObserverBatch( + scheduler: scheduler, + emittedTitles: &emittedTitles + ) + let metrics = MobileWorkspaceObserverMetrics.shared + metrics.reset(enable: true) + defer { metrics.reset(enable: false) } + + for index in 0..<100 { + workspace.title = "Burst \(index)" + } + + scheduler.advance(by: 0.049) + #expect(emittedTitles.isEmpty) + #expect(metrics.snapshot().batchDrains == 0) + scheduler.advance(by: 0.002) + + #expect(emittedTitles == ["Burst 99"]) + assertMobileTitleBurstMetrics(metrics.snapshot(), submitted: 100) + #expect(scheduler.activeDeadlineCount == 0) + } + + @Test func sustainedMobileWorkspaceMutationsDrainOnceWithFinalStateAtMaximumDeadline() throws { + let manager = TabManager(autoWelcomeIfNeeded: false) + let workspace = try #require(manager.selectedWorkspace) + let scheduler = VirtualInvalidationDeadlineScheduler() + var emittedTitles: [String] = [] + let observer = MobileWorkspaceListObserver( + tabManager: manager, + scheduler: scheduler.schedule, + emitWorkspaceUpdated: { emittedTitles.append(workspace.title) } + ) + defer { withExtendedLifetime(observer) {} } + + settleInitialObserverBatch( + scheduler: scheduler, + emittedTitles: &emittedTitles + ) + let metrics = MobileWorkspaceObserverMetrics.shared + metrics.reset(enable: true) + defer { metrics.reset(enable: false) } + + workspace.title = "Sustained 0" + for index in 1...4 { + scheduler.advance(by: 0.039) + workspace.title = "Sustained \(index)" + } + + #expect(emittedTitles.isEmpty) + #expect(metrics.snapshot().batchDrains == 0) + scheduler.advance(by: 0.005) + + #expect(emittedTitles == ["Sustained 4"]) + assertMobileTitleBurstMetrics(metrics.snapshot(), submitted: 5) + #expect(scheduler.activeDeadlineCount == 0) + } + + private func settleInitialObserverBatch( + scheduler: VirtualInvalidationDeadlineScheduler, + emittedTitles: inout [String] + ) { + scheduler.advance(by: 1) + emittedTitles.removeAll() + #expect(scheduler.activeDeadlineCount == 0) + } + + private func assertMobileTitleBurstMetrics( + _ snapshot: MobileWorkspaceObserverMetricsSnapshot, + submitted: Int + ) { + #expect(snapshot.enabled) + #expect(snapshot.invalidationsSubmitted == [ + "workspace_graph": 0, + "workspace": submitted, + "preview": 0, + "summary": 0, + ]) + #expect(snapshot.batchDrains == 1) + #expect(snapshot.invalidationsDrained == 1) + #expect(snapshot.workspacesRehashed == 1) + #expect(snapshot.emits == 1) + #expect(snapshot.skips == 0) + #expect(snapshot.fullGraphRebuilds == 0) + } +} + +@MainActor +private final class VirtualInvalidationDeadlineScheduler { + typealias Cancellation = @MainActor () -> Void + + private struct ScheduledAction { + let id: Int + let deadline: TimeInterval + let insertionOrder: Int + let action: @MainActor () -> Void + } + + private var now: TimeInterval = 0 + private var nextID = 0 + private var scheduled: [ScheduledAction] = [] + private var cancelled: Set = [] + + var activeDeadlineCount: Int { + scheduled.count { !cancelled.contains($0.id) } + } + + func schedule( + delay: TimeInterval, + action: @escaping @MainActor () -> Void + ) -> Cancellation { + nextID += 1 + let id = nextID + scheduled.append(ScheduledAction( + id: id, + deadline: now + max(0, delay), + insertionOrder: id, + action: action + )) + return { [weak self] in + self?.cancelled.insert(id) + } + } + + func advance(by delta: TimeInterval) { + let target = now + max(0, delta) + while let next = scheduled + .filter({ !cancelled.contains($0.id) && $0.deadline <= target }) + .min(by: { + ($0.deadline, $0.insertionOrder) < ($1.deadline, $1.insertionOrder) + }) { + scheduled.removeAll { $0.id == next.id } + now = next.deadline + next.action() + } + now = target + scheduled.removeAll { cancelled.contains($0.id) } + } +} diff --git a/cmuxTests/MemoryPressureMonitorTests.swift b/cmuxTests/MemoryPressureMonitorTests.swift index 96aaf465e00c..cb055c6e4627 100644 --- a/cmuxTests/MemoryPressureMonitorTests.swift +++ b/cmuxTests/MemoryPressureMonitorTests.swift @@ -40,6 +40,26 @@ private struct FixedMemoryPressureFootprintSampler: MemoryPressureFootprintSampl } } +@MainActor +private final class RecordingAppMemoryMonitoringServices: AppMemoryMonitoringServices { + private(set) var eventDrivenMonitorStartCount = 0 + + func startEventDrivenMemoryPressureMonitoring() { + eventDrivenMonitorStartCount += 1 + } +} + +@MainActor +struct AppMemoryMonitoringStartupTests { + @Test func startsEventDrivenPressureMonitor() { + let services = RecordingAppMemoryMonitoringServices() + + AppMemoryMonitoringStartup(services: services).start() + + #expect(services.eventDrivenMonitorStartCount == 1) + } +} + struct MemoryPressureStateTrackerTests { @Test func footprintThresholdsMapToSeverity() { let thresholds = MemoryPressureFootprintThresholds( diff --git a/cmuxTests/MobileHostAuthorizationTests.swift b/cmuxTests/MobileHostAuthorizationTests.swift index 4b84b52d33ff..263bb3edd109 100644 --- a/cmuxTests/MobileHostAuthorizationTests.swift +++ b/cmuxTests/MobileHostAuthorizationTests.swift @@ -953,6 +953,90 @@ struct MobileHostAuthorizationTests { #expect(!MobileHostService.debugHasEventSubscribersForTesting(topic: "terminal.updated")) #expect(!observer.debugIsRetainingNotificationDemandForTesting) } + @Test func testTerminalOutputDemandIgnoresUnrelatedTopics() async { + MobileHostService.debugResetEventSubscriptionsForTesting() + defer { MobileHostService.debugResetEventSubscriptionsForTesting() } + let session = makeSubscriptionTestSession() + + await session.subscribe(streamID: "events", topics: ["workspace.updated"]) + + #expect(!MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + _ = await session.unsubscribe(streamID: "events") + } + @Test func testEitherTerminalOutputTopicEnablesAggregateDemand() async { + MobileHostService.debugResetEventSubscriptionsForTesting() + defer { MobileHostService.debugResetEventSubscriptionsForTesting() } + let session = makeSubscriptionTestSession() + + await session.subscribe(streamID: "bytes", topics: ["terminal.bytes"]) + #expect(MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + _ = await session.unsubscribe(streamID: "bytes") + #expect(!MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + + await session.subscribe(streamID: "grid", topics: ["terminal.render_grid"]) + #expect(MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + _ = await session.unsubscribe(streamID: "grid") + #expect(!MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + } + @Test func testTerminalOutputDemandRemainsUntilFinalStreamUnsubscribes() async { + MobileHostService.debugResetEventSubscriptionsForTesting() + defer { MobileHostService.debugResetEventSubscriptionsForTesting() } + let session = makeSubscriptionTestSession() + + await session.subscribe(streamID: "bytes", topics: ["terminal.bytes"]) + await session.subscribe(streamID: "grid", topics: ["terminal.render_grid"]) + _ = await session.unsubscribe(streamID: "bytes") + #expect(MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + _ = await session.unsubscribe(streamID: "grid") + #expect(!MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + } + @Test func testTerminalOutputDemandReplacementIsIdempotent() async { + MobileHostService.debugResetEventSubscriptionsForTesting() + defer { MobileHostService.debugResetEventSubscriptionsForTesting() } + let session = makeSubscriptionTestSession() + + await session.subscribe(streamID: "events", topics: ["terminal.bytes"]) + await session.subscribe(streamID: "events", topics: ["terminal.bytes"]) + #expect(MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + _ = await session.unsubscribe(streamID: "events") + + #expect(!MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + } + @Test func testResetClearsTerminalOutputDemand() async { + MobileHostService.debugResetEventSubscriptionsForTesting() + defer { MobileHostService.debugResetEventSubscriptionsForTesting() } + let session = makeSubscriptionTestSession() + + await session.subscribe(streamID: "events", topics: ["terminal.render_grid"]) + #expect(MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + MobileHostService.debugResetEventSubscriptionsForTesting() + #expect(!MobileHostService.debugHasTerminalOutputSubscribersForTesting()) + } + @Test func testTerminalOutputActivationIsVisibleToFirstConcurrentAppend() async throws { + MobileHostService.debugResetEventSubscriptionsForTesting() + let surfaceID = UUID() + let tee = MobileTerminalByteTee.shared + tee.dropSurface(surfaceID: surfaceID) + defer { + tee.dropSurface(surfaceID: surfaceID) + MobileHostService.debugResetEventSubscriptionsForTesting() + } + let session = makeSubscriptionTestSession() + await session.subscribe(streamID: "grid", topics: ["terminal.render_grid"]) + + let bytes: [UInt8] = [0x61, 0x62, 0x63] + await Task.detached { + bytes.withUnsafeBufferPointer { + tee.append(surfaceID: surfaceID, bytes: $0) + } + }.value + + for _ in 0..<100 where tee.currentSequence(surfaceID: surfaceID) != UInt64(bytes.count) { + try await Task.sleep(nanoseconds: 1_000_000) + } + #expect(tee.currentSequence(surfaceID: surfaceID) == UInt64(bytes.count)) + _ = await session.unsubscribe(streamID: "grid") + } @Test func testMobileWorkspaceListHashIncludesDisplayedDirectories() { let workspace = Workspace( title: "Mobile", @@ -1150,6 +1234,20 @@ struct MobileHostAuthorizationTests { createdWorkspaceIDs: createdWorkspaceIDs ) } + private func makeSubscriptionTestSession() -> MobileHostConnection { + MobileHostConnection( + id: UUID(), + connection: NWConnection( + host: NWEndpoint.Host("127.0.0.1"), + port: NWEndpoint.Port(rawValue: 9)!, + using: .tcp + ), + authorizeRequest: { _ in nil }, + onAuthorizedRequest: { _ in }, + handleRequest: { _ in .ok([:]) }, + onClose: { _ in } + ) + } private func drainMobileHostMainQueue() async { await withCheckedContinuation { continuation in DispatchQueue.main.async { continuation.resume() } diff --git a/cmuxTests/MobileWorkspaceObserverMetricsTests.swift b/cmuxTests/MobileWorkspaceObserverMetricsTests.swift new file mode 100644 index 000000000000..3a86f4d317d2 --- /dev/null +++ b/cmuxTests/MobileWorkspaceObserverMetricsTests.swift @@ -0,0 +1,148 @@ +import Testing +import os + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct MobileWorkspaceObserverMetricsTests { + @Test func runtimeMetricsExposeAggregateReleaseSafeProofAndResetEpoch() throws { + let metrics = MobileWorkspaceObserverMetrics(enabled: true) + + metrics.recordInvalidationSubmitted(.workspaceGraph) + metrics.recordInvalidationSubmitted(.workspace) + metrics.recordInvalidationSubmitted(.workspace) + metrics.recordInvalidationSubmitted(.preview) + metrics.recordInvalidationSubmitted(.summary) + + let batchToken = metrics.batchDrainStarted(invalidationCount: 4) + metrics.operationCompleted(batchToken) + let graphToken = metrics.fullGraphRebuildStarted() + metrics.operationCompleted(graphToken, workspacesRehashed: 5) + let incrementalToken = metrics.incrementalRefreshStarted() + metrics.operationCompleted(incrementalToken, workspacesRehashed: 2) + let previewToken = metrics.previewSignaturesStarted() + metrics.operationCompleted(previewToken) + let summaryToken = metrics.summaryHashStarted() + metrics.operationCompleted(summaryToken) + metrics.recordEmit() + metrics.recordSkip() + + let snapshot = metrics.snapshot() + #expect(snapshot.schemaVersion == 1) + #expect(snapshot.enabled) + #expect(snapshot.invalidationsSubmitted == [ + "workspace_graph": 1, + "workspace": 2, + "preview": 1, + "summary": 1, + ]) + #expect(snapshot.batchDrains == 1) + #expect(snapshot.invalidationsDrained == 4) + #expect(snapshot.workspacesRehashed == 7) + #expect(snapshot.fullGraphRebuilds == 1) + #expect(snapshot.emits == 1) + #expect(snapshot.skips == 1) + #expect(snapshot.batchDrainDuration.count == 1) + #expect(snapshot.fullGraphRebuildDuration.count == 1) + #expect(snapshot.incrementalRefreshDuration.count == 1) + #expect(snapshot.previewSignaturesDuration.count == 1) + #expect(snapshot.summaryHashDuration.count == 1) + #expect(snapshot.foundationObject["schema_version"] as? Int == 1) + let durations = try #require(snapshot.foundationObject["duration_ms"] as? [String: Any]) + #expect(Set(durations.keys) == [ + "batch_drain", + "full_graph_rebuild", + "incremental_refresh", + "preview_signatures", + "summary_hash", + ]) + + let staleToken = metrics.fullGraphRebuildStarted() + metrics.reset(enable: true) + metrics.operationCompleted(staleToken, workspacesRehashed: 99) + + let resetSnapshot = metrics.snapshot() + #expect(resetSnapshot.enabled) + #expect(Set(resetSnapshot.invalidationsSubmitted.values) == [0]) + #expect(resetSnapshot.batchDrains == 0) + #expect(resetSnapshot.invalidationsDrained == 0) + #expect(resetSnapshot.workspacesRehashed == 0) + #expect(resetSnapshot.fullGraphRebuilds == 0) + #expect(resetSnapshot.emits == 0) + #expect(resetSnapshot.skips == 0) + #expect(resetSnapshot.fullGraphRebuildDuration.count == 0) + } + + @Test func runtimeMetricsDisabledLifecycleDoesNotAccumulate() { + // The process-wide store uses this default in Debug and Release. The + // diagnostics owner must opt in with reset(enable: true). + let metrics = MobileWorkspaceObserverMetrics() + + metrics.recordInvalidationSubmitted(.workspace) + #expect(metrics.batchDrainStarted(invalidationCount: 1) == nil) + #expect(metrics.fullGraphRebuildStarted() == nil) + #expect(metrics.incrementalRefreshStarted() == nil) + #expect(metrics.previewSignaturesStarted() == nil) + #expect(metrics.summaryHashStarted() == nil) + metrics.recordEmit() + metrics.recordSkip() + + let disabledSnapshot = metrics.snapshot() + #expect(!disabledSnapshot.enabled) + #expect(Set(disabledSnapshot.invalidationsSubmitted.values) == [0]) + #expect(disabledSnapshot.batchDrains == 0) + #expect(disabledSnapshot.fullGraphRebuilds == 0) + #expect(disabledSnapshot.emits == 0) + #expect(disabledSnapshot.skips == 0) + #expect(disabledSnapshot.summaryHashDuration.count == 0) + #expect(disabledSnapshot.foundationObject["enabled"] as? Bool == false) + + metrics.reset(enable: true) + metrics.recordInvalidationSubmitted(.summary) + metrics.recordEmit() + #expect(metrics.snapshot().enabled) + #expect(metrics.snapshot().invalidationsSubmitted["summary"] == 1) + #expect(metrics.snapshot().emits == 1) + + metrics.disable() + metrics.recordInvalidationSubmitted(.summary) + metrics.recordEmit() + let stoppedSnapshot = metrics.snapshot() + #expect(!stoppedSnapshot.enabled) + #expect(stoppedSnapshot.invalidationsSubmitted["summary"] == 1) + #expect(stoppedSnapshot.emits == 1) + } + + @Test func disabledFastPathDoesNotReadClockAcrossRecorderSurface() { + let clockReads = OSAllocatedUnfairLock(initialState: 0) + let metrics = MobileWorkspaceObserverMetrics( + enabled: false, + monotonicNanoseconds: { + clockReads.withLock { reads in + reads += 1 + return UInt64(reads) + } + } + ) + + for _ in 0..<10_000 { + metrics.recordInvalidationSubmitted(.workspaceGraph) + metrics.operationCompleted(metrics.batchDrainStarted(invalidationCount: 3)) + metrics.operationCompleted(metrics.fullGraphRebuildStarted(), workspacesRehashed: 3) + metrics.operationCompleted(metrics.incrementalRefreshStarted()) + metrics.operationCompleted(metrics.previewSignaturesStarted()) + metrics.operationCompleted(metrics.summaryHashStarted()) + metrics.recordEmit() + metrics.recordSkip() + } + + #expect(clockReads.withLock { $0 } == 0) + #expect(metrics.snapshot().batchDrains == 0) + #expect(metrics.snapshot().fullGraphRebuilds == 0) + #expect(metrics.snapshot().emits == 0) + } +} diff --git a/cmuxTests/PortScanSnapshotStoreTests.swift b/cmuxTests/PortScanSnapshotStoreTests.swift new file mode 100644 index 000000000000..bb5b043c4205 --- /dev/null +++ b/cmuxTests/PortScanSnapshotStoreTests.swift @@ -0,0 +1,298 @@ +import Darwin +import CmuxCore +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct PortScanSnapshotStoreTests { + @Test + func partialLibprocEvidenceDoesNotClearStablePorts() async { + let failedPID = 100 + let store = PortScanSnapshotStore( + captureWithEvidenceAndProof: { _ in + ( + PortLsofScanResult( + values: [:], + globallyComplete: true, + incompletePIDs: [failedPID] + ), + .libproc + ) + } + ) + + let partial = await store.evidencedSnapshot(pids: [failedPID], maximumAge: 0) + #expect(partial.completeness(for: [failedPID]) == .incomplete) + + var reconciler = PortScanSnapshotReconciler(missingPortRetentionLimit: 1) + _ = reconciler.reconcile( + scannedPorts: [failedPID: [4_200]], + scannedKeys: [failedPID], + trackedKeys: [failedPID], + completeness: .complete + ) + for _ in 0..<2 { + _ = reconciler.reconcile( + scannedPorts: [failedPID: []], + scannedKeys: [failedPID], + trackedKeys: [failedPID], + completeness: partial.completeness(for: [failedPID]) + ) + } + + #expect(reconciler.snapshot[failedPID] == [4_200]) + } + + @Test + func concurrentCoveredRequestsShareOneLibprocCapture() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledPortScanCapturer() + let clock = PortScanTestClock(now: Date(timeIntervalSince1970: 100)) + let store = PortScanSnapshotStore( + now: { await clock.read() }, + capture: { pids in await capturer.capture(pids: pids) }, + metrics: metricsStore + ) + + let first = Task { + await store.snapshot(pids: [10, 20], maximumAge: 2) + } + await capturer.waitForCallCount(1) + let covered = Task { + await store.snapshot(pids: [20], maximumAge: 2) + } + #expect(await waitForMetrics { + let reuse = metricsStore.snapshot().lsof.reuse + return reuse.cache + reuse.inFlight == 1 + }) + await capturer.releaseNext() + + let firstResult = await first.value + let coveredResult = await covered.value + #expect(firstResult == [10: [1_010], 20: [1_020]]) + #expect(coveredResult == firstResult) + #expect(await capturer.callCount() == 1) + #expect(await capturer.maximumConcurrentCaptures() == 1) + let metrics = metricsStore.snapshot() + #expect(metrics.lsof.started == 1) + #expect(metrics.lsof.completed == 1) + #expect(metrics.lsof.maximumInFlight == 1) + #expect(metrics.lsof.reuse.cache + metrics.lsof.reuse.inFlight == 1) + let wireLsof = metrics.foundationObject["lsof"] as? [String: Any] + #expect(wireLsof?["backend"] as? String == "libproc") + #expect(wireLsof?["process_launches"] as? Int == 0) + } + + @Test + func uncoveredRequestsCoalesceIntoOneBoundedFollowup() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledPortScanCapturer() + let clock = PortScanTestClock(now: Date(timeIntervalSince1970: 100)) + let store = PortScanSnapshotStore( + now: { await clock.read() }, + capture: { pids in await capturer.capture(pids: pids) }, + metrics: metricsStore + ) + + let first = Task { + await store.snapshot(pids: [10], maximumAge: 2) + } + await capturer.waitForCallCount(1) + let second = Task { + await store.snapshot(pids: [20], maximumAge: 2) + } + let third = Task { + await store.snapshot(pids: [30], maximumAge: 2) + } + #expect(await waitForMetrics { + metricsStore.snapshot().lsof.coalescedRequests == 2 + }) + + await capturer.releaseNext() + await capturer.waitForCallCount(2) + #expect(await capturer.capturedPIDRequests() == [[10], [20, 30]]) + #expect(await capturer.maximumConcurrentCaptures() == 1) + await capturer.releaseNext() + + #expect(await first.value == [10: [1_010]]) + #expect(await second.value == [20: [1_020], 30: [1_030]]) + #expect(await third.value == [20: [1_020], 30: [1_030]]) + #expect(await capturer.callCount() == 2) + #expect(await capturer.maximumConcurrentCaptures() == 1) + } + + @Test + func freshSupersetCacheServesSubsetsAndExpiryRefreshesListeners() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledPortScanCapturer(autoRelease: true) + let clock = PortScanTestClock(now: Date(timeIntervalSince1970: 100)) + let store = PortScanSnapshotStore( + now: { await clock.read() }, + capture: { pids in await capturer.capture(pids: pids) }, + metrics: metricsStore + ) + + let first = await store.snapshot(pids: [10, 20], maximumAge: 2) + let cached = await store.snapshot(pids: [20], maximumAge: 2) + await clock.advance(by: 3) + let refreshed = await store.snapshot(pids: [20], maximumAge: 2) + + #expect(first == [10: [1_010], 20: [1_020]]) + #expect(cached == first) + #expect(refreshed == [20: [1_020]]) + #expect(await capturer.callCount() == 2) + #expect(await capturer.capturedPIDRequests() == [[10, 20], [20]]) + let metrics = metricsStore.snapshot() + #expect(metrics.lsof.reuse.cache == 1) + } + + @Test + func staleCompletionCannotClearNewInFlightCaptureOrCache() async { + let capturer = ControlledPortScanCapturer(resultIncludesCaptureOrdinal: true) + let clock = SnapshotCompletionBarrierClock( + now: Date(timeIntervalSince1970: 100), + blockedReadNumbers: [3, 4] + ) + let completions = SnapshotTaskCompletionCounter() + let store = PortScanSnapshotStore( + now: { await clock.read() }, + capture: { pids in await capturer.capture(pids: pids) } + ) + + let first = Task { + let snapshot = await store.snapshot(pids: [10], maximumAge: 0) + await completions.record() + return snapshot + } + await capturer.waitForCallCount(1) + let second = Task { + let snapshot = await store.snapshot(pids: [10], maximumAge: 0) + await completions.record() + return snapshot + } + + await capturer.releaseNext() + await clock.waitForReadCount(4) + await clock.resumeRead(3) + await completions.waitForCount(1) + + await clock.advance(by: 1) + let refreshed = Task { + await store.snapshot(pids: [20], maximumAge: 0) + } + await capturer.waitForCallCount(2) + + await clock.resumeRead(4) + await completions.waitForCount(2) + await clock.advance(by: 1) + let joined = Task { + await store.snapshot(pids: [20], maximumAge: 0) + } + await clock.waitForReadCount(6) + for _ in 0..<10_000 { + if await capturer.callCount() >= 3 { break } + await Task.yield() + } + + await capturer.releaseAll() + let refreshedSnapshot = await refreshed.value + let joinedSnapshot = await joined.value + let cachedSnapshot = await store.snapshot(pids: [20], maximumAge: 10) + _ = await first.value + _ = await second.value + + #expect(refreshedSnapshot == [20: [20_020]]) + #expect(joinedSnapshot == refreshedSnapshot) + #expect(cachedSnapshot == refreshedSnapshot) + #expect(await capturer.callCount() == 2) + #expect(await capturer.maximumConcurrentCaptures() == 1) + } + + private func waitForMetrics( + _ predicate: @escaping @Sendable () -> Bool + ) async -> Bool { + for _ in 0..<10_000 { + if predicate() { return true } + await Task.yield() + } + return predicate() + } + + @Test + func captureProofSurfacesSubprocessBackendAndLaunchCount() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let store = PortScanSnapshotStore( + captureWithProof: { pids in + ( + Dictionary(uniqueKeysWithValues: pids.map { ($0, Set([8_000 + $0])) }), + ProcessPerformanceCaptureProof(backend: .subprocess, processLaunchCount: 1) + ) + }, + metrics: metricsStore + ) + + _ = await store.snapshot(pids: [42], maximumAge: 0) + let metrics = metricsStore.snapshot() + let wireLsof = metrics.foundationObject["lsof"] as? [String: Any] + + #expect(metrics.lsof.backendCounts == ["subprocess": 1]) + #expect(metrics.lsof.processLaunches == 1) + #expect(wireLsof?["backend"] as? String == "subprocess") + #expect(wireLsof?["process_launches"] as? Int == 1) + } + + @Test + func performanceExerciseUsesTwoRealSnapshotRequests() async { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let capturer = ControlledPortScanCapturer(autoRelease: true) + let store = PortScanSnapshotStore( + capture: { pids in await capturer.capture(pids: pids) }, + metrics: metricsStore + ) + + _ = await store.snapshot(pids: [42], maximumAge: 10) + metricsStore.reset(enable: true) + let exercise = await store.performanceMetricsExercise(pids: [42]) + let metrics = metricsStore.snapshot() + + #expect(exercise?.proof == .libproc) + #expect(exercise?.sharedResult == true) + #expect(metrics.lsof.started == 1) + #expect(metrics.lsof.completed == 1) + #expect(metrics.lsof.reuse.inFlight == 1) + #expect(await capturer.callCount() == 2) + } + + @Test + func cancellingPerformanceExerciseUnblocksNormalConsumers() async { + let metricsStore = ProcessPerformanceMetrics(enabled: false) + metricsStore.reset(enable: true) + let capturer = ControlledPortScanCapturer() + let store = PortScanSnapshotStore( + capture: { pids in await capturer.capture(pids: pids) }, + metrics: metricsStore + ) + let exercise = Task { await store.performanceMetricsExercise(pids: [42]) } + await capturer.waitForCallCount(1) + #expect(await waitForMetrics { + metricsStore.snapshot().lsof.reuse.inFlight == 1 + }) + + exercise.cancel() + let normal = Task { + await store.snapshot(pids: [42], maximumAge: 10) + } + await capturer.releaseNext() + + let cancelledExercise = await exercise.value + #expect(cancelledExercise?.proof == nil) + #expect(await normal.value == [42: [1_042]]) + #expect(metricsStore.snapshot().lsof.completed == 1) + } +} diff --git a/cmuxTests/PortScannerSharedSnapshotTests.swift b/cmuxTests/PortScannerSharedSnapshotTests.swift new file mode 100644 index 000000000000..b29604f45012 --- /dev/null +++ b/cmuxTests/PortScannerSharedSnapshotTests.swift @@ -0,0 +1,291 @@ +import Darwin +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct PortScannerSharedSnapshotTests { + @Test + func staleRevisionIsRejectedAndCounted() { +#if DEBUG + let metricsStore = ProcessPerformanceMetrics() + #expect(!PortScanner.acceptsResult( + currentRevision: 8, + expectedRevision: 7, + staleMetric: .portAgentRevision, + metrics: metricsStore + )) + #expect(PortScanner.acceptsResult( + currentRevision: 8, + expectedRevision: 8, + staleMetric: .portAgentRevision, + metrics: metricsStore + )) + let metrics = metricsStore.snapshot() + #expect(metrics.staleRejections[ProcessStaleRejection.portAgentRevision.rawValue] == 1) +#endif + } + + @Test + func panelMutationRejectsGenerationAdvancedWhileMainActorIsBusy() async { + let gate = PortScanner.ResultGenerationGate() + gate.advancePanel(to: 1) + let barrier = MainActorQueueBarrier() + barrier.blockMainActor() + await barrier.waitUntilBlocked() + + let mutation = Task { @MainActor in + gate.applyPanel(ifCurrent: 1) { true } + } + gate.advancePanel(to: 2) + barrier.releaseMainActor() + + #expect(await mutation.value == nil) + } + + @Test + func agentMutationRejectsGenerationAdvancedWhileMainActorIsBusy() async { + let workspaceId = UUID() + let gate = PortScanner.ResultGenerationGate() + gate.advanceAgent(workspaceId: workspaceId, to: 1) + let barrier = MainActorQueueBarrier() + barrier.blockMainActor() + await barrier.waitUntilBlocked() + + let mutation = Task { @MainActor in + gate.applyAgent(workspaceId: workspaceId, ifCurrent: 1) { true } + } + gate.advanceAgent(workspaceId: workspaceId, to: 2) + barrier.releaseMainActor() + + #expect(await mutation.value == nil) + } + + @Test + func processTreeExpansionIncludesForksAndDetachedAgentRoots() { + let firstWorkspace = UUID() + let detachedWorkspace = UUID() + let snapshot = processSnapshot([ + process(pid: 10, parentPID: 1), + process(pid: 20, parentPID: 10), + process(pid: 30, parentPID: 20), + process(pid: 90, parentPID: 1), + process(pid: 91, parentPID: 90) + ]) + + let expanded = PortScanner.expandAgentProcessTree( + agentPIDsByWorkspace: [ + firstWorkspace: [10], + detachedWorkspace: [90] + ], + processSnapshot: snapshot + ) + + #expect(expanded[10] == [firstWorkspace]) + #expect(expanded[20] == [firstWorkspace]) + #expect(expanded[30] == [firstWorkspace]) + #expect(expanded[90] == [detachedWorkspace]) + #expect(expanded[91] == [detachedWorkspace]) + } + + @Test + func descendantLookupDoesNotMixLiveChildrenIntoCapturedGeneration() throws { + let childInput = Pipe() + let liveChild = Process() + liveChild.executableURL = URL(fileURLWithPath: "/bin/cat") + liveChild.standardInput = childInput + liveChild.standardOutput = FileHandle.nullDevice + liveChild.standardError = FileHandle.nullDevice + try liveChild.run() + defer { + if liveChild.isRunning { + liveChild.terminate() + } + liveChild.waitUntilExit() + } + + let rootPID = Int(Darwin.getpid()) + let capturedChildPID = 999_999 + let snapshot = processSnapshot([ + process(pid: rootPID, parentPID: 1), + process(pid: capturedChildPID, parentPID: rootPID) + ]) + + #expect(liveChild.isRunning) + #expect(snapshot.descendantPIDs(rootPID: rootPID) == [capturedChildPID]) + } + + @Test + func socketInfoReportsIPv4AndIPv6ListenersButExcludesNonListeners() { + var ipv4Listener = socket_fdinfo() + ipv4Listener.psi.soi_kind = Int32(SOCKINFO_TCP) + ipv4Listener.psi.soi_protocol = IPPROTO_TCP + ipv4Listener.psi.soi_proto.pri_tcp.tcpsi_state = TSI_S_LISTEN + ipv4Listener.psi.soi_proto.pri_tcp.tcpsi_ini.insi_vflag = UInt8(INI_IPV4) + ipv4Listener.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport = Int32(UInt16(3_000).bigEndian) + + var ipv6Listener = ipv4Listener + ipv6Listener.psi.soi_proto.pri_tcp.tcpsi_ini.insi_vflag = UInt8(INI_IPV6) + ipv6Listener.psi.soi_proto.pri_tcp.tcpsi_ini.insi_lport = Int32(UInt16(9_229).bigEndian) + + var connected = ipv4Listener + connected.psi.soi_proto.pri_tcp.tcpsi_state = TSI_S_ESTABLISHED + var nonTCP = ipv4Listener + nonTCP.psi.soi_kind = Int32(SOCKINFO_IN) + + #expect(PortScanner.listeningTCPPort(from: ipv4Listener) == 3_000) + #expect(PortScanner.listeningTCPPort(from: ipv6Listener) == 9_229) + #expect(PortScanner.listeningTCPPort(from: connected) == nil) + #expect(PortScanner.listeningTCPPort(from: nonTCP) == nil) + } + + @Test + func libprocScanDiscoversLiveIPv4AndIPv6ListenersOnly() throws { + let ipv4 = try makeBoundTCPSocket(family: AF_INET, listening: true) + defer { Darwin.close(ipv4.descriptor) } + let ipv6 = try makeBoundTCPSocket(family: AF_INET6, listening: true) + defer { Darwin.close(ipv6.descriptor) } + let nonListener = try makeBoundTCPSocket(family: AF_INET, listening: false) + defer { Darwin.close(nonListener.descriptor) } + + let pid = Int(Darwin.getpid()) + let ports = PortScanner.scanListeningPorts(pids: [pid])[pid] ?? [] + + #expect(ports.contains(ipv4.port)) + #expect(ports.contains(ipv6.port)) + #expect(!ports.contains(nonListener.port)) + } + + private func processSnapshot(_ processes: [CmuxTopProcessInfo]) -> CmuxTopProcessSnapshot { + CmuxTopProcessSnapshot( + processes: processes, + sampledAt: Date(), + includesProcessDetails: false, + includesCMUXScope: false + ) + } + + private func process(pid: Int, parentPID: Int) -> CmuxTopProcessInfo { + CmuxTopProcessInfo( + pid: pid, + parentPID: parentPID, + name: "process-\(pid)", + path: nil, + ttyDevice: nil, + cmuxWorkspaceID: nil, + cmuxSurfaceID: nil, + cmuxAttributionReason: nil, + processGroupID: pid, + terminalProcessGroupID: nil, + cpuPercent: 0, + residentBytes: 0, + virtualBytes: 0, + threadCount: 1 + ) + } + + private func makeBoundTCPSocket( + family: Int32, + listening: Bool + ) throws -> (descriptor: Int32, port: Int) { + let descriptor = Darwin.socket(family, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw currentPOSIXError() } + + do { + let port: Int + switch family { + case AF_INET: + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr = in_addr(s_addr: INADDR_LOOPBACK.bigEndian) + try bindSocket(descriptor, address: &address) + try startListeningIfNeeded(descriptor, listening: listening) + var length = socklen_t(MemoryLayout.size) + guard withUnsafeMutablePointer(to: &address, { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.getsockname(descriptor, $0, &length) + } + }) == 0 else { + throw currentPOSIXError() + } + port = Int(UInt16(bigEndian: address.sin_port)) + case AF_INET6: + var address = sockaddr_in6() + address.sin6_len = UInt8(MemoryLayout.size) + address.sin6_family = sa_family_t(AF_INET6) + address.sin6_addr = in6addr_loopback + try bindSocket(descriptor, address: &address) + try startListeningIfNeeded(descriptor, listening: listening) + var length = socklen_t(MemoryLayout.size) + guard withUnsafeMutablePointer(to: &address, { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.getsockname(descriptor, $0, &length) + } + }) == 0 else { + throw currentPOSIXError() + } + port = Int(UInt16(bigEndian: address.sin6_port)) + default: + throw POSIXError(.EAFNOSUPPORT) + } + return (descriptor, port) + } catch { + Darwin.close(descriptor) + throw error + } + } + + private func bindSocket
( + _ descriptor: Int32, + address: inout Address + ) throws { + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout
.size)) + } + } + guard result == 0 else { throw currentPOSIXError() } + } + + private func startListeningIfNeeded( + _ descriptor: Int32, + listening: Bool + ) throws { + guard listening else { return } + guard Darwin.listen(descriptor, 1) == 0 else { throw currentPOSIXError() } + } + + private func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} +private final class MainActorQueueBarrier: @unchecked Sendable { + private let entered = DispatchSemaphore(value: 0) + private let release = DispatchSemaphore(value: 0) + + func blockMainActor() { + DispatchQueue.main.async { [self] in + entered.signal() + release.wait() + } + } + + func waitUntilBlocked() async { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { [self] in + entered.wait() + continuation.resume() + } + } + } + + func releaseMainActor() { + release.signal() + } +} diff --git a/cmuxTests/ProcessPerformanceMetricsEpochTests.swift b/cmuxTests/ProcessPerformanceMetricsEpochTests.swift new file mode 100644 index 000000000000..24192eebde80 --- /dev/null +++ b/cmuxTests/ProcessPerformanceMetricsEpochTests.swift @@ -0,0 +1,145 @@ +import Darwin +import Foundation +import Testing +import os + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct ProcessPerformanceMetricsEpochTests { + @Test + func disabledCollectionDoesNotAccumulateUntilExplicitReset() { + let metricsStore = ProcessPerformanceMetrics(enabled: false) + let token = metricsStore.processSnapshotCaptureStarted( + generation: 1, + requirementsRawValue: 0 + ) + metricsStore.processSnapshotCaptureCompleted(token, generation: 1, processCount: 3) + + #expect(metricsStore.snapshot().enabled == false) + #expect(metricsStore.snapshot().processSnapshots.captureStarted == 0) + #expect(metricsStore.snapshot().foundationObject["schema_version"] as? Int == 3) + #expect(metricsStore.snapshot().foundationObject["enabled"] as? Bool == false) + + metricsStore.reset(enable: true) + let enabledToken = metricsStore.processSnapshotCaptureStarted( + generation: 2, + requirementsRawValue: 0 + ) + metricsStore.processSnapshotCaptureCompleted(enabledToken, generation: 2, processCount: 3) + + #expect(metricsStore.snapshot().enabled == true) + #expect(metricsStore.snapshot().processSnapshots.captureStarted == 1) + + metricsStore.disable() + #expect(metricsStore.snapshot().enabled == false) + } + + @Test + func monotonicMeasurementEpochDistinguishesResetsInTheSameWallClockMillisecond() { + let metricsStore = ProcessPerformanceMetrics( + enabled: false, + unixMilliseconds: { 1_752_345_678_901 } + ) + + metricsStore.reset(enable: true) + let first = metricsStore.snapshot() + metricsStore.reset(enable: true) + let second = metricsStore.snapshot() + + #expect(first.resetAtUnixMilliseconds == second.resetAtUnixMilliseconds) + #expect(first.measurementEpoch == 1) + #expect(second.measurementEpoch == 2) + #expect(first.foundationObject["measurement_epoch"] as? UInt64 == 1) + #expect(second.foundationObject["measurement_epoch"] as? UInt64 == 2) + } + + @Test + func measurementEpochWrapSkipsReservedZero() { + #expect(ProcessPerformanceMetrics.nextMeasurementEpoch(after: .max) == 1) + } + + @Test + func completionsFromBeforeResetDoNotEnterTheNewMeasurementEpoch() { + let metricsStore = ProcessPerformanceMetrics(enabled: true) + let processToken = metricsStore.processSnapshotCaptureStarted( + generation: 1, + requirementsRawValue: 0 + ) + let lsofToken = metricsStore.lsofStarted(pidCount: 3) + let operationToken = metricsStore.operationStarted(.portFilter, inputCount: 3) + + metricsStore.reset() + metricsStore.processSnapshotCaptureCompleted( + processToken, + generation: 1, + processCount: 3 + ) + metricsStore.recordLsofReuse(.cache, token: lsofToken) + metricsStore.recordLsofCoalescedRequest(token: lsofToken) + metricsStore.lsofCompleted(lsofToken) + metricsStore.operationCompleted(operationToken, outputCount: 2) + + let metrics = metricsStore.snapshot() + #expect(metrics.processSnapshots.captureStarted == 0) + #expect(metrics.processSnapshots.captureCompleted == 0) + #expect(metrics.processSnapshots.inFlight == 0) + #expect(metrics.generations.isEmpty) + #expect(metrics.requestCountsByConsumer.isEmpty) + #expect(metrics.lsof.started == 0) + #expect(metrics.lsof.completed == 0) + #expect(metrics.lsof.inFlight == 0) + #expect(metrics.lsof.coalescedRequests == 0) + #expect(metrics.lsof.reuse == ProcessPerformanceReuseMetrics()) + #expect(metrics.operations.isEmpty) + } + + @Test + func disabledFastPathDoesNotReadClockAcrossRecorderSurface() { + let clockReads = OSAllocatedUnfairLock(initialState: 0) + let metricsStore = ProcessPerformanceMetrics( + enabled: false, + monotonicNanoseconds: { + clockReads.withLock { reads in + reads += 1 + return UInt64(reads) + } + } + ) + + for generation in 1...10_000 { + let capture = metricsStore.processSnapshotCaptureStarted( + generation: UInt64(generation), + requirementsRawValue: 3 + ) + metricsStore.recordProcessSnapshotRequest(consumer: .systemTop) + metricsStore.recordProcessSnapshotReuse( + consumer: .systemTop, + generation: UInt64(generation), + source: .cache, + token: capture + ) + metricsStore.processSnapshotCaptureCompleted( + capture, + generation: UInt64(generation), + processCount: 3 + ) + let lsof = metricsStore.lsofStarted(pidCount: 3) + metricsStore.recordLsofReuse(.cache, token: lsof) + metricsStore.recordLsofCoalescedRequest(token: lsof) + metricsStore.lsofCompleted(lsof) + let operation = metricsStore.operationStarted(.portFilter, inputCount: 3) + metricsStore.operationCompleted(operation, outputCount: 1) + metricsStore.recordStaleRejection(.portPanelRevision) + } + + #expect(clockReads.withLock { $0 } == 0) + #expect(metricsStore.snapshot().processSnapshots.captureStarted == 0) + #expect(metricsStore.snapshot().lsof.started == 0) + #expect(metricsStore.snapshot().operations.isEmpty) + } +} diff --git a/cmuxTests/ProcessSnapshotCentralizationTests.swift b/cmuxTests/ProcessSnapshotCentralizationTests.swift new file mode 100644 index 000000000000..3242ed7f0b77 --- /dev/null +++ b/cmuxTests/ProcessSnapshotCentralizationTests.swift @@ -0,0 +1,171 @@ +import CmuxAgentChat +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite +struct ProcessSnapshotCentralizationTests { + @Test + func liveRuntimeConsumersShareOneCentralCapture() async throws { + let capturer = CentralizedProcessSnapshotCapturer() + let clock = CentralizedProcessSnapshotClock(now: Date(timeIntervalSince1970: 100)) + let store = CmuxTopProcessSnapshotStore( + now: { await clock.read() }, + capture: { requirements in + await capturer.capture(requirements: requirements) + } + ) + let homeDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-process-centralization-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: homeDirectory) } + + let surfaceID = UUID().uuidString + let liveness = Task { + await AgentChatSessionRegistry.liveAgentPID( + surfaceID: surfaceID, + kind: .claude, + matchingSessionIDs: ["session"], + snapshotStore: store + ) + } + await capturer.waitForCallCount(1) + + let resumeIndexes = Task { + await ProcessDetectedResumeIndexes.load( + homeDirectory: homeDirectory.path, + fileManager: .default, + snapshotStore: store + ) + } + await clock.waitForReadCount(2) + await capturer.releaseNext() + + #expect(await liveness.value == nil) + _ = await resumeIndexes.value + #expect(await capturer.callCount() == 1) + #expect(await capturer.maximumConcurrentCaptures() == 1) + #expect(await capturer.capturedRequirements() == [[.processDetails, .cmuxScope]]) + } + + @Test + func synchronousCompatibilityCaptureIsIncludedInProofMetrics() { + let metrics = ProcessPerformanceMetrics(enabled: true) + + let snapshot = CmuxTopProcessSnapshot.captureSynchronouslyForCompatibility( + includeProcessDetails: true, + includeCMUXScope: true, + metrics: metrics, + captureWithProof: { includeProcessDetails, includeCMUXScope in + ( + CmuxTopProcessSnapshot( + processes: [], + sampledAt: Date(timeIntervalSince1970: 101), + includesProcessDetails: includeProcessDetails, + includesCMUXScope: includeCMUXScope + ), + .libproc + ) + } + ) + + let proof = metrics.snapshot().processSnapshots + #expect(snapshot.hasCMUXScope) + #expect(proof.captureStarted == 1) + #expect(proof.captureCompleted == 1) + #expect(proof.inFlight == 0) + #expect(proof.maximumInFlight == 1) + } +} + +private actor CentralizedProcessSnapshotCapturer { + private var requirements: [CmuxTopProcessSnapshotRequirements] = [] + private var activeCaptures = 0 + private var maximumActiveCaptures = 0 + private var releases: [CheckedContinuation] = [] + private var callCountWaiters: [(Int, CheckedContinuation)] = [] + + func capture(requirements: CmuxTopProcessSnapshotRequirements) async -> CmuxTopProcessSnapshot { + self.requirements.append(requirements) + activeCaptures += 1 + maximumActiveCaptures = max(maximumActiveCaptures, activeCaptures) + resumeSatisfiedCallCountWaiters() + await withCheckedContinuation { continuation in + releases.append(continuation) + } + activeCaptures -= 1 + return CmuxTopProcessSnapshot( + processes: [], + sampledAt: Date(timeIntervalSince1970: TimeInterval(self.requirements.count)), + includesProcessDetails: requirements.contains(.processDetails), + includesCMUXScope: requirements.contains(.cmuxScope) + ) + } + + func waitForCallCount(_ count: Int) async { + guard requirements.count < count else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((count, continuation)) + } + } + + func releaseNext() { + releases.removeFirst().resume() + } + + func callCount() -> Int { + requirements.count + } + + func maximumConcurrentCaptures() -> Int { + maximumActiveCaptures + } + + func capturedRequirements() -> [CmuxTopProcessSnapshotRequirements] { + requirements + } + + private func resumeSatisfiedCallCountWaiters() { + let satisfied = callCountWaiters.filter { requirements.count >= $0.0 } + callCountWaiters.removeAll { requirements.count >= $0.0 } + for (_, continuation) in satisfied { + continuation.resume() + } + } +} + +private actor CentralizedProcessSnapshotClock { + private let now: Date + private var readCount = 0 + private var readCountWaiters: [(Int, CheckedContinuation)] = [] + + init(now: Date) { + self.now = now + } + + func read() -> Date { + readCount += 1 + resumeSatisfiedReadCountWaiters() + return now + } + + func waitForReadCount(_ count: Int) async { + guard readCount < count else { return } + await withCheckedContinuation { continuation in + readCountWaiters.append((count, continuation)) + } + } + + private func resumeSatisfiedReadCountWaiters() { + let satisfied = readCountWaiters.filter { readCount >= $0.0 } + readCountWaiters.removeAll { readCount >= $0.0 } + for (_, continuation) in satisfied { + continuation.resume() + } + } +} diff --git a/cmuxTests/ProcessSnapshotTestSupport.swift b/cmuxTests/ProcessSnapshotTestSupport.swift new file mode 100644 index 000000000000..ef21d1ba5d03 --- /dev/null +++ b/cmuxTests/ProcessSnapshotTestSupport.swift @@ -0,0 +1,296 @@ +import Darwin +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +actor ControlledProcessSnapshotCapturer { + private let autoRelease: Bool + private let returnsEmptySnapshot: Bool + private var requirements: [CmuxTopProcessSnapshotRequirements] = [] + private var activeCaptures = 0 + private var maximumActiveCaptures = 0 + private var releases: [CheckedContinuation] = [] + private var callCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(autoRelease: Bool = false, returnsEmptySnapshot: Bool = false) { + self.autoRelease = autoRelease + self.returnsEmptySnapshot = returnsEmptySnapshot + } + + func capture(requirements: CmuxTopProcessSnapshotRequirements) async -> CmuxTopProcessSnapshot { + self.requirements.append(requirements) + activeCaptures += 1 + maximumActiveCaptures = max(maximumActiveCaptures, activeCaptures) + resumeSatisfiedCallCountWaiters() + if !autoRelease { + await withCheckedContinuation { continuation in + releases.append(continuation) + } + } + activeCaptures -= 1 + let processes = returnsEmptySnapshot ? [] : [ + CmuxTopProcessInfo( + pid: 4_242, + parentPID: 1, + name: "fixture", + path: nil, + ttyDevice: nil, + cmuxWorkspaceID: nil, + cmuxSurfaceID: nil, + cmuxAttributionReason: nil, + processGroupID: nil, + terminalProcessGroupID: nil, + cpuPercent: 0, + residentBytes: 1, + virtualBytes: 1, + threadCount: 1 + ) + ] + return CmuxTopProcessSnapshot( + processes: processes, + sampledAt: Date(timeIntervalSince1970: TimeInterval(self.requirements.count)), + includesProcessDetails: requirements.contains(.processDetails), + includesCMUXScope: requirements.contains(.cmuxScope) + ) + } + + func waitForCallCount(_ count: Int) async { + guard requirements.count < count else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((count, continuation)) + } + } + + func releaseNext() { + guard !releases.isEmpty else { return } + releases.removeFirst().resume() + } + + func releaseAll() { + let pending = releases + releases.removeAll(keepingCapacity: true) + for continuation in pending { + continuation.resume() + } + } + + func callCount() -> Int { + requirements.count + } + + func capturedRequirements() -> [CmuxTopProcessSnapshotRequirements] { + requirements + } + + func maximumConcurrentCaptures() -> Int { + maximumActiveCaptures + } + + private func resumeSatisfiedCallCountWaiters() { + let satisfied = callCountWaiters.filter { requirements.count >= $0.count } + callCountWaiters.removeAll { requirements.count >= $0.count } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +actor ProcessSnapshotTestClock { + private var now: Date + + init(now: Date) { + self.now = now + } + + func read() -> Date { + now + } + + func advance(by interval: TimeInterval) { + now = now.addingTimeInterval(interval) + } +} + +actor ControlledPortScanCapturer { + private let autoRelease: Bool + private let resultIncludesCaptureOrdinal: Bool + private var requests: [Set] = [] + private var activeCaptures = 0 + private var maximumActiveCaptures = 0 + private var releases: [CheckedContinuation] = [] + private var callCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init( + autoRelease: Bool = false, + resultIncludesCaptureOrdinal: Bool = false + ) { + self.autoRelease = autoRelease + self.resultIncludesCaptureOrdinal = resultIncludesCaptureOrdinal + } + + func capture(pids: Set) async -> [Int: Set] { + requests.append(pids) + let captureOrdinal = requests.count + activeCaptures += 1 + maximumActiveCaptures = max(maximumActiveCaptures, activeCaptures) + resumeSatisfiedCallCountWaiters() + if !autoRelease { + await withCheckedContinuation { continuation in + releases.append(continuation) + } + } + activeCaptures -= 1 + let portOffset = resultIncludesCaptureOrdinal ? captureOrdinal * 10_000 : 1_000 + return Dictionary(uniqueKeysWithValues: pids.map { ($0, [$0 + portOffset]) }) + } + + func waitForCallCount(_ count: Int) async { + guard requests.count < count else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((count, continuation)) + } + } + + func releaseNext() { + guard !releases.isEmpty else { return } + releases.removeFirst().resume() + } + + func releaseAll() { + let pending = releases + releases.removeAll(keepingCapacity: true) + for continuation in pending { + continuation.resume() + } + } + + func callCount() -> Int { + requests.count + } + + func capturedPIDRequests() -> [Set] { + requests + } + + func maximumConcurrentCaptures() -> Int { + maximumActiveCaptures + } + + private func resumeSatisfiedCallCountWaiters() { + let satisfied = callCountWaiters.filter { requests.count >= $0.count } + callCountWaiters.removeAll { requests.count >= $0.count } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +actor PortScanTestClock { + private var now: Date + private var readCount = 0 + private var readCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(now: Date) { + self.now = now + } + + func read() -> Date { + readCount += 1 + let satisfied = readCountWaiters.filter { readCount >= $0.count } + readCountWaiters.removeAll { readCount >= $0.count } + for waiter in satisfied { + waiter.continuation.resume() + } + return now + } + + func waitForReadCount(_ count: Int) async { + guard readCount < count else { return } + await withCheckedContinuation { continuation in + readCountWaiters.append((count, continuation)) + } + } + + func advance(by interval: TimeInterval) { + now = now.addingTimeInterval(interval) + } +} + +actor SnapshotCompletionBarrierClock { + private let blockedReadNumbers: Set + private var now: Date + private var readCount = 0 + private var blockedReads: [Int: (Date, CheckedContinuation)] = [:] + private var readCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + init(now: Date, blockedReadNumbers: Set) { + self.now = now + self.blockedReadNumbers = blockedReadNumbers + } + + func read() async -> Date { + readCount += 1 + let readNumber = readCount + let value = now + if blockedReadNumbers.contains(readNumber) { + return await withCheckedContinuation { continuation in + blockedReads[readNumber] = (value, continuation) + resumeSatisfiedReadCountWaiters() + } + } + resumeSatisfiedReadCountWaiters() + return value + } + + func waitForReadCount(_ count: Int) async { + guard readCount < count else { return } + await withCheckedContinuation { continuation in + readCountWaiters.append((count, continuation)) + } + } + + func resumeRead(_ readNumber: Int) { + guard let (value, continuation) = blockedReads.removeValue(forKey: readNumber) else { + return + } + continuation.resume(returning: value) + } + + func advance(by interval: TimeInterval) { + now = now.addingTimeInterval(interval) + } + + private func resumeSatisfiedReadCountWaiters() { + let satisfied = readCountWaiters.filter { readCount >= $0.count } + readCountWaiters.removeAll { readCount >= $0.count } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +actor SnapshotTaskCompletionCounter { + private var count = 0 + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record() { + count += 1 + let satisfied = waiters.filter { count >= $0.count } + waiters.removeAll { count >= $0.count } + for waiter in satisfied { + waiter.continuation.resume() + } + } + + func waitForCount(_ count: Int) async { + guard self.count < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} diff --git a/cmuxTests/SharedLiveAgentIndexAgentLivenessTests.swift b/cmuxTests/SharedLiveAgentIndexAgentLivenessTests.swift index 85dbc39c351b..ba090f46c05b 100644 --- a/cmuxTests/SharedLiveAgentIndexAgentLivenessTests.swift +++ b/cmuxTests/SharedLiveAgentIndexAgentLivenessTests.swift @@ -11,6 +11,15 @@ import Testing @MainActor @Suite(.serialized) struct SharedLiveAgentIndexAgentLivenessTests { + @Test + func hookEventReloadCadenceScalesWithLiveAgentCount() { + #expect(SharedLiveAgentIndex.hookEventReloadInterval(liveAgentCount: 0) == 5) + #expect(SharedLiveAgentIndex.hookEventReloadInterval(liveAgentCount: 8) == 5) + #expect(SharedLiveAgentIndex.hookEventReloadInterval(liveAgentCount: 16) == 10) + #expect(SharedLiveAgentIndex.hookEventReloadInterval(liveAgentCount: 32) == 20) + #expect(SharedLiveAgentIndex.hookEventReloadInterval(liveAgentCount: 64) == 30) + } + @Test func forkAvailabilityIgnoresDeadUnrelatedPanelChildProcess() async throws { let fm = FileManager.default diff --git a/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift b/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift deleted file mode 100644 index 53ce2709ac75..000000000000 --- a/cmuxTests/TabManagerBackgroundWorkspaceMountBoundTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@MainActor -@Suite(.serialized) -struct TabManagerBackgroundWorkspaceMountBoundTests { - // Regression coverage for issue #7136: a burst of eagerly-loaded background - // workspaces (e.g. dozens of scripted or auto-resumed agent workspaces) must - // NOT all force-mount into the single main-window SwiftUI GraphHost. When they - // do, `GraphHost.flushTransactions()` performs O(number-of-hosted-panes) work - // every runloop tick and pins the main thread at 100%+ CPU. The mounted - // background-load set that inflates `reconcileMountedWorkspaceIds`' mount cap - // must therefore stay bounded to a small constant no matter how many - // workspaces request a background prime. - @Test func backgroundWorkspaceMountsStayBoundedUnderEagerLoadBurst() { - let manager = TabManager() - - let workspaceIds = (0..<50).map { _ in UUID() } - for id in workspaceIds { - manager.requestBackgroundWorkspaceLoad(for: id) - manager.retainBackgroundWorkspaceMount(for: id) - } - - // Anchor to the production cap so any change to the limit is re-justified - // against this guard, not silently re-admitting O(all panes) growth (#7136). - #expect( - manager.mountedBackgroundWorkspaceLoadIds.count <= TabManager.maxConcurrentBackgroundWorkspaceMounts, - "Background-prime mounts must stay bounded regardless of how many workspaces are eagerly loaded (#7136)." - ) - } - - // The bound must be a *concurrency* limit with reusable slots, not a lifetime - // cap — otherwise background priming would stall permanently after the first - // few workspaces. Releasing a retained mount must free a slot for another. - @Test func backgroundWorkspaceMountSlotIsReusableAfterRelease() { - let manager = TabManager() - - let workspaceIds = (0..<10).map { _ in UUID() } - for id in workspaceIds { - manager.retainBackgroundWorkspaceMount(for: id) - } - - let mountedAfterBurst = manager.mountedBackgroundWorkspaceLoadIds - #expect(mountedAfterBurst.count <= TabManager.maxConcurrentBackgroundWorkspaceMounts) - #expect(!mountedAfterBurst.isEmpty) - - guard let releasedId = mountedAfterBurst.first, - let refusedId = workspaceIds.first(where: { !mountedAfterBurst.contains($0) }) else { - Issue.record("Expected at least one retained and one refused background mount") - return - } - - manager.releaseBackgroundWorkspaceMount(for: releasedId) - manager.retainBackgroundWorkspaceMount(for: refusedId) - - #expect( - manager.mountedBackgroundWorkspaceLoadIds.contains(refusedId), - "A freed background-mount slot must be reusable by another workspace (#7136)." - ) - #expect( - manager.mountedBackgroundWorkspaceLoadIds.count <= TabManager.maxConcurrentBackgroundWorkspaceMounts, - "The background-mount set must remain bounded after slot reuse (#7136)." - ) - } -} diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 29749c603bda..cc5cdaefb7e1 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2561,146 +2561,6 @@ struct TerminalKeyboardCopyModeCursorSwiftTests { } -final class GhosttyBackgroundThemeTests: XCTestCase { - func testColorClampsOpacity() { - let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) - - let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0) - XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001) - - let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0) - XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001) - } - - func testColorFromNotificationUsesBackgroundAndOpacity() { - let fallbackColor = NSColor.black - let fallbackOpacity = 1.0 - let notification = Notification( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: [ - GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), - GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), - ] - ) - - let actual = GhosttyBackgroundTheme.color( - from: notification, - fallbackColor: fallbackColor, - fallbackOpacity: fallbackOpacity - ) - guard let srgb = actual.usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005) - XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005) - XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005) - XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005) - } - - func testColorFromNotificationFallsBackWhenPayloadMissing() { - let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0) - let fallbackOpacity = 0.42 - let notification = Notification(name: .ghosttyDefaultBackgroundDidChange) - - let actual = GhosttyBackgroundTheme.color( - from: notification, - fallbackColor: fallbackColor, - fallbackOpacity: fallbackOpacity - ) - guard let srgb = actual.usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005) - XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005) - XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005) - XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005) - } -} - -final class PanelAppearanceBackgroundTests: XCTestCase { - func testTransparentGhosttyOpacityUsesClearContentBackground() { - var config = GhosttyConfig() - config.backgroundColor = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) - config.backgroundOpacity = 0.42 - config.backgroundBlur = .disabled - - let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) - - XCTAssertTrue(appearance.usesClearContentBackground) - XCTAssertFalse(appearance.drawsContentBackground) - XCTAssertEqual(appearance.backgroundColor.alphaComponent, 0.42, accuracy: 0.0001) - XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 0.0, accuracy: 0.0001) - } - - func testOpaqueGhosttyBackgroundKeepsPanelFill() { - var config = GhosttyConfig() - config.backgroundColor = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) - config.backgroundOpacity = 1.0 - config.backgroundBlur = .disabled - - let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) - - XCTAssertFalse(appearance.usesClearContentBackground) - XCTAssertTrue(appearance.drawsContentBackground) - XCTAssertEqual(appearance.backgroundColor.alphaComponent, 1.0, accuracy: 0.0001) - XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 1.0, accuracy: 0.0001) - } - - func testLowContrastPanelForegroundFallsBackToReadableColor() { - var config = GhosttyConfig() - config.backgroundColor = NSColor(hex: "#FFFFFF")! - config.backgroundOpacity = 1.0 - config.foregroundColor = NSColor(hex: "#FFFFFF")! - - let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) - - XCTAssertEqual(appearance.foregroundColor.hexString(), "#000000") - } - - func testReadablePanelForegroundPreservesThemeColor() { - var config = GhosttyConfig() - config.backgroundColor = NSColor(hex: "#000000")! - config.backgroundOpacity = 1.0 - config.foregroundColor = NSColor(hex: "#FDF6E3")! - - let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) - - XCTAssertEqual(appearance.foregroundColor.hexString(), "#FDF6E3") - } - - func testGhosttyGlassBackgroundUsesClearContentBackground() { - var config = GhosttyConfig() - config.backgroundOpacity = 1.0 - config.backgroundBlur = .macosGlassRegular - - let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) - - XCTAssertTrue(appearance.usesClearContentBackground) - XCTAssertFalse(appearance.drawsContentBackground) - XCTAssertEqual(appearance.backgroundColor.alphaComponent, 1.0, accuracy: 0.0001) - XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 0.0, accuracy: 0.0001) - } - - func testTransparentWindowSettingUsesClearContentBackground() { - var config = GhosttyConfig() - config.backgroundOpacity = 1.0 - config.backgroundBlur = .disabled - - let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: true) - - XCTAssertTrue(appearance.usesClearContentBackground) - XCTAssertFalse(appearance.drawsContentBackground) - XCTAssertEqual(appearance.backgroundColor.alphaComponent, 1.0, accuracy: 0.0001) - XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 0.0, accuracy: 0.0001) - } -} - - final class GhosttyResponderResolutionTests: XCTestCase { private final class FocusProbeView: NSView { override var acceptsFirstResponder: Bool { true } @@ -3116,7 +2976,10 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { XCTFail("Expected terminal surface view") return } - XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating the detach race") + guard waitUntil(timeout: 15, condition: { surface.surface != nil }) else { + XCTFail("Expected runtime surface before simulating the detach race") + return + } surface.releaseSurfaceForTesting() XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup") @@ -3138,6 +3001,86 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { #endif } + func testKeyDownRecoveryReplaysTriggeringEventExactlyOnce() throws { +#if DEBUG + let window = makeWindow() + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else { + XCTFail("Expected terminal surface view") + return + } + guard waitUntil(timeout: 15, condition: { surface.surface != nil }) else { + XCTFail("Expected runtime surface before simulating the detach race") + return + } + + let previousTextInputEventHandler = GhosttyNSView.debugTextInputEventHandler + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + defer { + GhosttyNSView.debugTextInputEventHandler = previousTextInputEventHandler + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + withExtendedLifetime(surface) {} + } + + GhosttyNSView.debugTextInputEventHandler = { view, event in + view.insertText( + event.characters ?? "", + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + return true + } + var forwardedTexts: [String] = [] + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + guard keyEvent.action == GHOSTTY_ACTION_PRESS, + keyEvent.keycode == 0, + let text = keyEvent.text else { + return + } + forwardedTexts.append(String(cString: text)) + } + + surface.releaseSurfaceForTesting() + hostedView.removeFromSuperview() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window") + + let event = makeKeyEvent(characters: "a", keyCode: 0, window: window) + surfaceView.keyDown(with: event) + waitForRuntimeSurface(surface) + + XCTAssertTrue( + waitUntil(timeout: 5) { forwardedTexts == ["a"] }, + "The keyDown that demanded runtime recreation must reach Ghostty exactly once after the surface is ready" + ) +#else + throw XCTSkip("Debug-only regression test") +#endif + } + func testKeyDownRecoveryDoesNotReplayFocusAfterResponderMovesAway() throws { #if DEBUG let window = makeWindow() diff --git a/cmuxTests/VaultAgentProcessCandidateSelectorTests.swift b/cmuxTests/VaultAgentProcessCandidateSelectorTests.swift new file mode 100644 index 000000000000..2c23839b6c88 --- /dev/null +++ b/cmuxTests/VaultAgentProcessCandidateSelectorTests.swift @@ -0,0 +1,416 @@ +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class VaultAgentProcessCandidateSelectorTests: XCTestCase { + func testBuiltInRegistryFetchesEveryRawBufferButFullyDecodesOnlyCandidates() { + let processCount = 2_048 + let agentCount = 64 + let workspaceID = UUID() + let processes = (0.. CmuxTopProcessSnapshot { + CmuxTopProcessSnapshot( + processes: processes, + sampledAt: Date(timeIntervalSince1970: 0), + includesProcessDetails: true + ) + } + + private func processInfo( + pid: Int, + workspaceID: UUID, + panelID: UUID, + name: String, + path: String, + processGroupID: Int? = nil, + terminalProcessGroupID: Int? = nil + ) -> CmuxTopProcessInfo { + CmuxTopProcessInfo( + pid: pid, + parentPID: 1, + name: name, + path: path, + ttyDevice: nil, + cmuxWorkspaceID: workspaceID, + cmuxSurfaceID: panelID, + cmuxAttributionReason: "candidate-selector-test", + processGroupID: processGroupID, + terminalProcessGroupID: terminalProcessGroupID, + cpuPercent: 0, + residentBytes: 0, + virtualBytes: 0, + threadCount: 1 + ) + } + + private func kernProcArgs( + arguments: [String], + environmentEntries: [String] + ) -> [UInt8] { + precondition(!arguments.isEmpty) + var argc = Int32(arguments.count).littleEndian + var bytes = withUnsafeBytes(of: &argc) { Array($0) } + appendCString(arguments[0], to: &bytes) + bytes.append(0) + for argument in arguments { + appendCString(argument, to: &bytes) + } + for entry in environmentEntries { + appendCString(entry, to: &bytes) + } + return bytes + } + + private func appendCString(_ value: String, to bytes: inout [UInt8]) { + bytes.append(contentsOf: value.utf8) + bytes.append(0) + } + + private func writeProjectAgentConfig( + projectRoot: URL, + id: String, + argumentNeedle: String + ) throws { + let configDirectory = projectRoot.appendingPathComponent(".cmux", isDirectory: true) + try FileManager.default.createDirectory(at: configDirectory, withIntermediateDirectories: true) + try """ + { + "vault": { + "agents": [ + { + "id": "\(id)", + "name": "Project Agent", + "detect": { "argvContains": "\(argumentNeedle)" }, + "sessionIdSource": { "type": "argvOption", "argvOption": "--session" }, + "resumeCommand": "project-agent --session {{sessionId}}" + } + ] + } + } + """.write( + to: configDirectory.appendingPathComponent("cmux.json"), + atomically: true, + encoding: .utf8 + ) + } +} diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index c3dec53d1808..3b9eab5d4265 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -25,6 +25,46 @@ final class WorkspaceContentViewVisibilityTests { } } + @Test + @MainActor + func ordinaryWorkspaceMountsOnlyTheSelectedPanelViewInEachPane() { + let workspace = Workspace(initialSurface: .cloudVMLoading) + + switch workspace.bonsplitController.configuration.contentViewLifecycle { + case .recreateOnSwitch: + break + case .keepAllAlive: + Issue.record( + "An ordinary workspace must not keep hidden panel view trees mounted; panel models own terminal and browser runtime state across selection changes." + ) + } + } + + @Test + func remoteTmuxEmbeddedLayoutKeepsAllMirroredPaneViewsMounted() { + let configuration = BonsplitConfiguration().remoteTmuxEmbedded + + switch configuration.contentViewLifecycle { + case .keepAllAlive: + break + case .recreateOnSwitch: + Issue.record("Remote tmux mirrors require every mirrored pane view to remain mounted.") + } + } + + @Test + @MainActor + func dockKeepsAllTabViewsMounted() { + let configuration = DockSplitStore.makeConfiguration() + + switch configuration.contentViewLifecycle { + case .keepAllAlive: + break + case .recreateOnSwitch: + Issue.record("Dock tab view state must remain mounted across tab selection changes.") + } + } + @Test @MainActor func testMinimalModeToggleDoesNotReevaluateChromeHeavyBodies() async throws { diff --git a/ghostty b/ghostty index eb500e9f45c8..a6305908a396 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit eb500e9f45c8b6ffa6043350ec1488a42d195406 +Subproject commit a6305908a39646ed87764aadd4ec8364c1d939a0 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index b5c79d0b2d8c..fea8805dad29 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -62,3 +62,4 @@ bdf4baa802746a49d60648b402cafbff50cdd8b0 981c63b1005b87bcf3ec1da97dc768382c146b0 e215e78bf04df3f7cecbef665eec051a203baf6a 308161b30d9111e84d039326fbf314d52340c759c034d2547388a2043ba64394 5ae712a89479f16d47d9a75e1a802a22415a3033 4cd7b251534890b63a83ac49feef2b99a835af88d8a1c7523df5aa1a6caa1d02 eb500e9f45c8b6ffa6043350ec1488a42d195406 47810027ffd9bc19873752d3698da2d60899f2140dacf3271f826de0c958178e +a6305908a39646ed87764aadd4ec8364c1d939a0 93f0b2b8dd992dad80b8def0d4b7e2b8f1ade7aa6df600987d9c4cb1e893b394