diff --git a/Packages/CmuxGit/Sources/CmuxGit/GitProbeConcurrencyLimiter.swift b/Packages/CmuxGit/Sources/CmuxGit/GitProbeConcurrencyLimiter.swift new file mode 100644 index 000000000000..e343be84c096 --- /dev/null +++ b/Packages/CmuxGit/Sources/CmuxGit/GitProbeConcurrencyLimiter.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Bounds how many git-metadata probes parse index files at the same time. +/// +/// Each tracked workspace panel schedules a detached probe that reads and +/// parses the entire `.git/index`. A burst — a workspace restore, the periodic +/// fallback refresh, or a flurry of file-system watch events — can schedule one +/// probe per panel at once. With dozens of panels that spawns dozens of +/// parallel parses and saturates every core, producing the multi-core CPU +/// spikes tracked in https://github.com/manaflow-ai/cmux/issues/4639. +/// +/// This actor caps how many probes run concurrently. Callers wrap the parse in +/// ``run(_:)``; work beyond the cap suspends until a slot frees and resumes in +/// FIFO order. Construct one and share it across every probe: +/// +/// ```swift +/// let limiter = GitProbeConcurrencyLimiter(maxConcurrent: 4) +/// let snapshot = await limiter.run { await parseIndex() } +/// ``` +public actor GitProbeConcurrencyLimiter { + private let maxConcurrent: Int + private var available: Int + private var waiters: [CheckedContinuation] = [] + + /// Creates a limiter allowing at most `maxConcurrent` probes to run at once. + /// + /// - Parameter maxConcurrent: The concurrency ceiling. Values below 1 are + /// clamped to 1 so the limiter always makes forward progress. + public init(maxConcurrent: Int) { + let ceiling = max(1, maxConcurrent) + self.maxConcurrent = ceiling + self.available = ceiling + } + + /// Runs `body` once a slot is free, releasing the slot when it returns. + /// + /// While `body` is suspended on its own `await`s the actor is free to admit + /// other callers up to the ceiling; only the slot accounting is serialized. + /// + /// - Parameter body: The probe work to run while holding a slot. + /// - Returns: Whatever `body` returns. + public func run(_ body: @Sendable () async -> T) async -> T { + await acquire() + defer { release() } + return await body() + } + + private func acquire() async { + if available > 0 { + available -= 1 + return + } + // Resumed by `release()`, which hands off its slot directly without + // bumping `available`, so the slot count stays balanced. + await withCheckedContinuation { waiters.append($0) } + } + + private func release() { + if waiters.isEmpty { + available += 1 + } else { + waiters.removeFirst().resume() + } + } +} diff --git a/Packages/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift b/Packages/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift index ebcac2296010..088fac3e1875 100644 --- a/Packages/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift +++ b/Packages/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Index.swift @@ -1,5 +1,27 @@ import Foundation +/// Lowercase-hex digit table used by ``lowercaseHexString(_:)``. +private let lowercaseHexDigits: [UInt8] = Array("0123456789abcdef".utf8) + +/// Lowercase hex encoding of a byte collection, without `String(format:)`. +/// +/// `String(format: "%02x", byte)` acquires a process-global locale lock +/// (`localeconv_l` / `os_unfair_lock`) on every call. Encoding a 20-byte +/// object ID for every index entry, across thousands of entries and many git +/// probes running in parallel, turned that lock into a multi-core CPU storm +/// (https://github.com/manaflow-ai/cmux/issues/4639). This table-based encoder +/// produces byte-identical output, allocation-light and lock-free. +func lowercaseHexString(_ bytes: Bytes) -> String +where Bytes.Element == UInt8 { + var ascii = [UInt8]() + ascii.reserveCapacity(bytes.count * 2) + for byte in bytes { + ascii.append(lowercaseHexDigits[Int(byte >> 4)]) + ascii.append(lowercaseHexDigits[Int(byte & 0x0f)]) + } + return String(decoding: ascii, as: UTF8.self) +} + extension GitMetadataService { /// Compares the working tree against the parsed index to decide dirtiness, /// returning the index signatures alongside. @@ -86,9 +108,7 @@ extension GitMetadataService { let mtimeNanoseconds = readBigEndianUInt32(bytes, at: offset + 12) let mode = readBigEndianUInt32(bytes, at: offset + 24) let size = readBigEndianUInt32(bytes, at: offset + 36) - let objectID = bytes[(offset + 40)..<(offset + 60)].map { - String(format: "%02x", $0) - }.joined() + let objectID = lowercaseHexString(bytes[(offset + 40)..<(offset + 60)]) let flags = readBigEndianUInt16(bytes, at: offset + 60) let pathLength = Int(flags & 0x0fff) let hasExtendedFlags = version >= 3 && (flags & 0x4000) != 0 @@ -157,7 +177,7 @@ extension GitMetadataService { } } - let checksum = bytes[(bytes.count - 20)..> (8 * (7 - $0))) } + return lowercaseHexString(hashBytes) } /// The submodule's currently checked-out commit for a parent gitlink entry, @@ -251,7 +272,7 @@ extension GitMetadataService { guard let data = try? Data(contentsOf: indexURL), data.count >= 20 else { return nil } - return data.suffix(20).map { String(format: "%02x", $0) }.joined() + return lowercaseHexString(data.suffix(20)) } /// Decodes a git index v4 path strip-length varint, advancing `offset`. diff --git a/Packages/CmuxGit/Tests/CmuxGitTests/GitProbeConcurrencyLimiterTests.swift b/Packages/CmuxGit/Tests/CmuxGitTests/GitProbeConcurrencyLimiterTests.swift new file mode 100644 index 000000000000..2a484595ede0 --- /dev/null +++ b/Packages/CmuxGit/Tests/CmuxGitTests/GitProbeConcurrencyLimiterTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import CmuxGit + +/// Records how many bodies run between ``enter()`` and ``leave()`` at once so a +/// test can assert the limiter never exceeds its ceiling. +private actor ConcurrencyTracker { + private(set) var current = 0 + private(set) var peak = 0 + private(set) var completed = 0 + + func enter() { + current += 1 + peak = max(peak, current) + } + + func leave() { + current -= 1 + completed += 1 + } +} + +@Suite struct GitProbeConcurrencyLimiterTests { + /// 60 tasks through a ceiling of 3 must never run more than 3 bodies at + /// once, and every task must still complete. + @Test func capsConcurrencyAndCompletesEveryTask() async { + let limiter = GitProbeConcurrencyLimiter(maxConcurrent: 3) + let tracker = ConcurrencyTracker() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<60 { + group.addTask { + await limiter.run { + await tracker.enter() + await Task.yield() + await Task.yield() + await tracker.leave() + } + } + } + } + #expect(await tracker.peak <= 3) + #expect(await tracker.peak >= 1) + #expect(await tracker.completed == 60) + } + + /// A ceiling of 1 strictly serializes: despite yields, no two bodies are + /// ever in flight together. + @Test func ceilingOfOneSerializes() async { + let limiter = GitProbeConcurrencyLimiter(maxConcurrent: 1) + let tracker = ConcurrencyTracker() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<25 { + group.addTask { + await limiter.run { + await tracker.enter() + await Task.yield() + await tracker.leave() + } + } + } + } + #expect(await tracker.peak == 1) + #expect(await tracker.completed == 25) + } + + /// A non-positive ceiling is clamped to 1 rather than deadlocking on zero + /// available slots. + @Test func clampsNonPositiveCeilingToOne() async { + let limiter = GitProbeConcurrencyLimiter(maxConcurrent: 0) + let tracker = ConcurrencyTracker() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<10 { + group.addTask { + await limiter.run { + await tracker.enter() + await Task.yield() + await tracker.leave() + } + } + } + } + #expect(await tracker.peak == 1) + #expect(await tracker.completed == 10) + } +} diff --git a/Packages/CmuxGit/Tests/CmuxGitTests/HexEncodingTests.swift b/Packages/CmuxGit/Tests/CmuxGitTests/HexEncodingTests.swift new file mode 100644 index 000000000000..87e5a4f1131d --- /dev/null +++ b/Packages/CmuxGit/Tests/CmuxGitTests/HexEncodingTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import CmuxGit + +/// Verifies the table-based hex encoder that replaced per-byte +/// `String(format: "%02x")` in the git index parser produces byte-identical +/// output. The replacement removed a process-global locale lock that turned +/// parallel git probes into a multi-core CPU storm +/// (https://github.com/manaflow-ai/cmux/issues/4639). +@Suite struct HexEncodingTests { + @Test func matchesPrintfFormatForEveryByteValue() { + for value in UInt8.min...UInt8.max { + #expect(lowercaseHexString([value]) == String(format: "%02x", value)) + } + } + + @Test func matchesPrintfFormatForObjectIDWidthSequence() { + // 20 bytes is the SHA-1 object-ID width formatted per index entry, + // the hot path in gitIndexSnapshot. + let bytes: [UInt8] = (0..<20).map { UInt8(($0 * 37 + 5) & 0xff) } + #expect(lowercaseHexString(bytes) == bytes.map { String(format: "%02x", $0) }.joined()) + #expect(lowercaseHexString(bytes).count == 40) + } + + @Test func encodesEmptyCollectionAsEmptyString() { + #expect(lowercaseHexString([UInt8]()) == "") + } + + @Test func matchesPrintfFormatForArraySliceAndDataSuffix() { + // The real call sites pass an ArraySlice (object ID / checksum) and a + // Data.suffix (file signature); both must encode identically. + let bytes: [UInt8] = [0x00, 0x0f, 0xa0, 0xff, 0x10, 0x01, 0x7e] + let slice = bytes[1..<6] + #expect(lowercaseHexString(slice) == slice.map { String(format: "%02x", $0) }.joined()) + let data = Data(bytes) + #expect( + lowercaseHexString(data.suffix(4)) + == data.suffix(4).map { String(format: "%02x", $0) }.joined() + ) + } + + @Test func matchesPrintfFormatForUInt64BigEndianHash() { + // The FNV content signature was %016llx of a UInt64; the replacement + // encodes the 8 big-endian bytes and must match for full-width values. + let values: [UInt64] = [0, 1, 0x0123_4567_89ab_cdef, .max, 0xff] + for value in values { + let bytes = (0..<8).map { UInt8(truncatingIfNeeded: value >> (8 * (7 - $0))) } + #expect(lowercaseHexString(bytes) == String(format: "%016llx", CUnsignedLongLong(value))) + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a9a642e00c99..61aae60e9749 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1200,6 +1200,10 @@ class TabManager: ObservableObject { // slugs) off the main actor. Stateless; the reads are pure functions of the // directory argument. private let gitMetadataService: GitMetadataService + // Caps how many git-index parses run at once so a burst of per-panel probes + // (workspace restore, fallback timer, file-watch flurry) can't saturate + // every core (https://github.com/manaflow-ai/cmux/issues/4639). + private let gitProbeLimiter = GitProbeConcurrencyLimiter(maxConcurrent: 4) // Resolves GitHub PR badges (slug resolution, REST fetch, candidate // matching). Stateless; the repo cache stays here in @@ -3005,29 +3009,31 @@ class TabManager: ObservableObject { private nonisolated func initialWorkspaceGitMetadataSnapshot( for directory: String ) async -> InitialWorkspaceGitMetadataSnapshot { - let metadata = await gitMetadataService.workspaceMetadata(for: directory) - guard metadata.isRepository else { + await gitProbeLimiter.run { + let metadata = await self.gitMetadataService.workspaceMetadata(for: directory) + guard metadata.isRepository else { + return InitialWorkspaceGitMetadataSnapshot( + isRepository: false, + branch: nil, + isDirty: false, + indexSignature: nil, + indexContentSignature: nil, + headSignature: nil, + pullRequest: .notFound + ) + } + + let branch = GitMetadataService.normalizedBranchName(metadata.branch) return InitialWorkspaceGitMetadataSnapshot( - isRepository: false, - branch: nil, - isDirty: false, - indexSignature: nil, - indexContentSignature: nil, - headSignature: nil, - pullRequest: .notFound + isRepository: true, + branch: branch, + isDirty: metadata.isDirty, + indexSignature: metadata.indexSignature, + indexContentSignature: metadata.indexContentSignature, + headSignature: metadata.headSignature, + pullRequest: branch == nil ? .notFound : .deferred ) } - - let branch = GitMetadataService.normalizedBranchName(metadata.branch) - return InitialWorkspaceGitMetadataSnapshot( - isRepository: true, - branch: branch, - isDirty: metadata.isDirty, - indexSignature: metadata.indexSignature, - indexContentSignature: metadata.indexContentSignature, - headSignature: metadata.headSignature, - pullRequest: branch == nil ? .notFound : .deferred - ) } func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {