Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Foundation

extension GitMetadataService {
private nonisolated static let gitIndexHexAlphabet = Array("0123456789abcdef".utf8)

/// Compares the working tree against the parsed index to decide dirtiness,
/// returning the index signatures alongside.
///
Expand Down Expand Up @@ -86,9 +88,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 = gitIndexHexString(bytes[(offset + 40)..<(offset + 60)])
let flags = readBigEndianUInt16(bytes, at: offset + 60)
let pathLength = Int(flags & 0x0fff)
let hasExtendedFlags = version >= 3 && (flags & 0x4000) != 0
Expand Down Expand Up @@ -157,7 +157,7 @@ extension GitMetadataService {
}
}

let checksum = bytes[(bytes.count - 20)..<bytes.count].map { String(format: "%02x", $0) }.joined()
let checksum = gitIndexHexString(bytes[(bytes.count - 20)..<bytes.count])
return GitIndexSnapshot(
entries: entries,
signature: checksum,
Expand Down Expand Up @@ -198,7 +198,27 @@ extension GitMetadataService {
appendString(entry.objectID)
appendByte(0)
}
return String(format: "%016llx", CUnsignedLongLong(hash))
return gitIndexFixedWidthHexString(hash)
}

private nonisolated static func gitIndexHexString<S: Sequence>(_ bytes: S) -> String where S.Element == UInt8 {
var encoded: [UInt8] = []
encoded.reserveCapacity(bytes.underestimatedCount * 2)
for byte in bytes {
encoded.append(gitIndexHexAlphabet[Int(byte >> 4)])
encoded.append(gitIndexHexAlphabet[Int(byte & 0x0f)])
}
return String(decoding: encoded, as: UTF8.self)
}

private nonisolated static func gitIndexFixedWidthHexString(_ value: UInt64) -> String {
var encoded = Array(repeating: UInt8(ascii: "0"), count: 16)
var remaining = value
for index in stride(from: 15, through: 0, by: -1) {
encoded[index] = gitIndexHexAlphabet[Int(remaining & 0x0f)]
remaining >>= 4
}
return String(decoding: encoded, as: UTF8.self)
}

/// The submodule's currently checked-out commit for a parent gitlink entry,
Expand Down Expand Up @@ -251,7 +271,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 gitIndexHexString(data.suffix(20))
}

/// Decodes a git index v4 path strip-length varint, advancing `offset`.
Expand Down
19 changes: 19 additions & 0 deletions Packages/CmuxGit/Tests/CmuxGitTests/GitMetadataServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,25 @@ import Testing
#expect(snapshot.entries.map(\.path) == [longPrefix + "first.txt", longPrefix + "second.txt"])
}

@Test func indexHexFieldsDecodeWithoutChangingObjectAndSignatureText() throws {
let fixture = try GitRepositoryFixture()
try fixture.writeBranch("main")
let objectID = "000102030405060708090a0b0c0d0e0f10111213"
let trailer = Array(UInt8(0x14)...UInt8(0x27))
try fixture.writeIndex(GitIndexFixture(
version: 2,
entries: [
GitIndexFixture.Entry(path: "tracked.txt", objectID: objectID),
],
trailer: trailer
))

let indexURL = fixture.gitDirectory.appendingPathComponent("index")
let snapshot = try #require(GitMetadataService.gitIndexSnapshot(indexURL: indexURL))
#expect(snapshot.entries.map(\.objectID) == [objectID])
#expect(snapshot.signature == "1415161718191a1b1c1d1e1f2021222324252627")
}

// MARK: Content signature stability

@Test func contentSignatureIgnoresStatOnlyChanges() {
Expand Down
195 changes: 185 additions & 10 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,76 @@ typealias Tab = Workspace

private let tabManagerLogger = Logger(subsystem: "com.cmuxterm.app", category: "TabManager")

protocol WorkspaceGitMetadataReading: Sendable {
func workspaceMetadata(for directory: String) async -> GitWorkspaceMetadata
}

extension GitMetadataService: WorkspaceGitMetadataReading {}

private struct WorkspaceGitMetadataProbeWaiter {
let id: UUID
let continuation: CheckedContinuation<Bool, Never>
}

actor WorkspaceGitMetadataProbeLimiter {
static let shared = WorkspaceGitMetadataProbeLimiter(limit: 2)
Comment on lines +31 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-injectable singleton for the permit limiter

WorkspaceGitMetadataReading was correctly made injectable (and the test stubs it out), but WorkspaceGitMetadataProbeLimiter.shared is a module-level singleton referenced directly inside the Task.detached body. This means the limiter's 2-permit semantics cannot be varied or isolated in tests — all tests share its live state. If two test cases that exercise the probe path run concurrently, they compete for the same 2 permits. Passing the limiter through TabManager's initializer (with the shared instance as the default) would mirror the pattern already applied to workspaceGitMetadataReader and eliminate the shared mutable singleton.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


private let limit: Int
private var activeCount = 0
private var waiters: [WorkspaceGitMetadataProbeWaiter] = []
private var cancelledWaiterIds: Set<UUID> = []

init(limit: Int) {
self.limit = max(1, limit)
}

func acquire() async -> Bool {
let id = UUID()
guard !Task.isCancelled else { return false }
if activeCount < limit {
activeCount += 1
return true
}

return await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
if cancelledWaiterIds.remove(id) != nil {
continuation.resume(returning: false)
} else {
waiters.append(WorkspaceGitMetadataProbeWaiter(id: id, continuation: continuation))
}
}
} onCancel: {
Task {
await self.cancelWaiter(id: id)
}
}
}

func release() {
guard activeCount > 0 else { return }
while !waiters.isEmpty {
let waiter = waiters.removeFirst()
if cancelledWaiterIds.remove(waiter.id) != nil {
waiter.continuation.resume(returning: false)
continue
}
waiter.continuation.resume(returning: true)
return
}
activeCount -= 1
}

private func cancelWaiter(id: UUID) {
if let index = waiters.firstIndex(where: { $0.id == id }) {
let waiter = waiters.remove(at: index)
waiter.continuation.resume(returning: false)
} else {
cancelledWaiterIds.insert(id)
}
}
Comment on lines +80 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale UUID accumulation in cancelledWaiterIds

There is a race window where release() can dequeue a waiter from waiters and resume its continuation with true before the onCancel-spawned cancelWaiter task runs on the actor. When cancelWaiter eventually executes, it finds the ID absent from waiters and inserts it into cancelledWaiterIds. That ID is now permanently stale: release() only cleans cancelledWaiterIds entries for IDs it encounters inside the waiters loop, so an ID for a waiter that was already dequeued is never evicted. Under a probe storm followed by rapid panel closes, cancelledWaiterIds can accumulate unboundedly.

Rule Used: Flag new blocking or timing-based synchronization ... (source)

}

enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
case top
case afterCurrent
Expand Down Expand Up @@ -980,6 +1050,11 @@ class TabManager: ObservableObject {
let panelId: UUID
}

private struct WorkspaceGitSnapshotProbeRequest: Sendable {
let probeKey: WorkspaceGitProbeKey
let isLastAttempt: Bool
}

private enum WorkspaceGitProbeState: Equatable {
case idle
case inFlight(rerunPending: Bool)
Expand Down Expand Up @@ -1139,6 +1214,9 @@ class TabManager: ObservableObject {
private var workspaceGitMetadataWatcherSourceDirectoryByKey: [WorkspaceGitProbeKey: String] = [:]
private var workspaceGitMetadataWatcherDescriptorRequestsByKey: [WorkspaceGitProbeKey: WorkspaceGitMetadataWatcherDescriptorRequest] = [:]
private var workspaceGitMetadataWatcherDescriptorGeneration: UInt64 = 0
private var workspaceGitSnapshotRequestsByDirectory: [String: [WorkspaceGitSnapshotProbeRequest]] = [:]
private var workspaceGitSnapshotTasksByDirectory: [String: Task<Void, Never>] = [:]
private var workspaceGitSnapshotDirectoryByProbeKey: [WorkspaceGitProbeKey: String] = [:]
private var workspaceGitMetadataFallbackTask: Task<Void, Never>?
private var lastSidebarGitMetadataWatchEnabled = SidebarWorkspaceDetailDefaults.watchGitStatusValue(defaults: .standard)
private var lastSidebarPullRequestPollingEnabled = SidebarWorkspaceDetailDefaults.pullRequestPollingEnabled(defaults: .standard)
Expand Down Expand Up @@ -1200,6 +1278,7 @@ class TabManager: ObservableObject {
// slugs) off the main actor. Stateless; the reads are pure functions of the
// directory argument.
private let gitMetadataService: GitMetadataService
private let workspaceGitMetadataReader: any WorkspaceGitMetadataReading

// Resolves GitHub PR badges (slug resolution, REST fetch, candidate
// matching). Stateless; the repo cache stays here in
Expand All @@ -1217,10 +1296,12 @@ class TabManager: ObservableObject {
autoWelcomeIfNeeded: Bool = true,
commandRunner: any CommandRunning = CommandRunner(),
gitMetadataService: GitMetadataService = GitMetadataService(),
workspaceGitMetadataReader: (any WorkspaceGitMetadataReading)? = nil,
gitPollClock: any GitPollClock = SystemGitPollClock()
) {
self.commandRunner = commandRunner
self.gitMetadataService = gitMetadataService
self.workspaceGitMetadataReader = workspaceGitMetadataReader ?? gitMetadataService
self.gitPollClock = gitPollClock
#if DEBUG
self.pullRequestProbeService = PullRequestProbeService(
Expand Down Expand Up @@ -1301,6 +1382,9 @@ class TabManager: ObservableObject {
for task in workspaceGitProbeTasksByKey.values {
task.cancel()
}
for task in workspaceGitSnapshotTasksByDirectory.values {
task.cancel()
}
workspacePullRequestRefreshTask?.cancel()
}

Expand Down Expand Up @@ -1423,6 +1507,7 @@ class TabManager: ObservableObject {
task.cancel()
}
workspaceGitProbeTasksByKey.removeAll()
cancelAllWorkspaceGitSnapshotTasks()
workspaceGitTrackedDirectoryByKey.removeAll()
workspaceGitCleanIndexSignatureByKey.removeAll()
workspaceGitCleanIndexContentSignatureByKey.removeAll()
Expand Down Expand Up @@ -2715,27 +2800,116 @@ class TabManager: ObservableObject {
return
}

Task.detached(priority: .utility) { [weak self] in
guard let snapshot = await self?.initialWorkspaceGitMetadataSnapshot(for: expectedDirectory) else {
return
enqueueWorkspaceGitMetadataSnapshotRequest(
probeKey: probeKey,
expectedDirectory: expectedDirectory,
isLastAttempt: isLastAttempt
)
}

private func enqueueWorkspaceGitMetadataSnapshotRequest(
probeKey: WorkspaceGitProbeKey,
expectedDirectory: String,
isLastAttempt: Bool
) {
let request = WorkspaceGitSnapshotProbeRequest(
probeKey: probeKey,
isLastAttempt: isLastAttempt
)
if let currentDirectory = workspaceGitSnapshotDirectoryByProbeKey[probeKey],
currentDirectory != expectedDirectory {
removeWorkspaceGitSnapshotRequest(for: probeKey)
}
workspaceGitSnapshotDirectoryByProbeKey[probeKey] = expectedDirectory
if var requests = workspaceGitSnapshotRequestsByDirectory[expectedDirectory],
let existingRequestIndex = requests.firstIndex(where: { $0.probeKey == probeKey }) {
requests[existingRequestIndex] = request
workspaceGitSnapshotRequestsByDirectory[expectedDirectory] = requests
} else {
workspaceGitSnapshotRequestsByDirectory[expectedDirectory, default: []].append(request)
}
guard workspaceGitSnapshotTasksByDirectory[expectedDirectory] == nil else {
#if DEBUG
cmuxDebugLog(
"workspace.gitProbe.joinSnapshot dir=\(expectedDirectory) " +
"queued=\(workspaceGitSnapshotRequestsByDirectory[expectedDirectory]?.count ?? 0)"
)
#endif
return
}

let reader = workspaceGitMetadataReader
workspaceGitSnapshotTasksByDirectory[expectedDirectory] = Task.detached(priority: .utility) { [weak self] in
let didAcquirePermit = await WorkspaceGitMetadataProbeLimiter.shared.acquire()
guard didAcquirePermit else { return }
defer {
Task {
await WorkspaceGitMetadataProbeLimiter.shared.release()
}
}

guard !Task.isCancelled else { return }
let snapshot = await Self.initialWorkspaceGitMetadataSnapshot(
for: expectedDirectory,
reader: reader
)
guard !Task.isCancelled else { return }
await MainActor.run { [weak self] in
self?.applyWorkspaceGitMetadataSnapshot(
guard !Task.isCancelled else { return }
self?.applyWorkspaceGitMetadataSnapshotBatch(
snapshot,
probeKey: probeKey,
expectedDirectory: expectedDirectory,
isLastAttempt: isLastAttempt
expectedDirectory: expectedDirectory
)
}
}
}

private func applyWorkspaceGitMetadataSnapshotBatch(
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
expectedDirectory: String
) {
workspaceGitSnapshotTasksByDirectory.removeValue(forKey: expectedDirectory)
let requests = workspaceGitSnapshotRequestsByDirectory.removeValue(forKey: expectedDirectory) ?? []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for request in requests {
workspaceGitSnapshotDirectoryByProbeKey.removeValue(forKey: request.probeKey)
applyWorkspaceGitMetadataSnapshot(
snapshot,
probeKey: request.probeKey,
expectedDirectory: expectedDirectory,
isLastAttempt: request.isLastAttempt
)
}
}

private func removeWorkspaceGitSnapshotRequest(for key: WorkspaceGitProbeKey) {
guard let directory = workspaceGitSnapshotDirectoryByProbeKey.removeValue(forKey: key),
var requests = workspaceGitSnapshotRequestsByDirectory[directory] else {
return
}
requests.removeAll { $0.probeKey == key }
if requests.isEmpty {
workspaceGitSnapshotRequestsByDirectory.removeValue(forKey: directory)
workspaceGitSnapshotTasksByDirectory.removeValue(forKey: directory)?.cancel()
} else {
workspaceGitSnapshotRequestsByDirectory[directory] = requests
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func cancelAllWorkspaceGitSnapshotTasks() {
for task in workspaceGitSnapshotTasksByDirectory.values {
task.cancel()
}
workspaceGitSnapshotTasksByDirectory.removeAll()
workspaceGitSnapshotRequestsByDirectory.removeAll()
workspaceGitSnapshotDirectoryByProbeKey.removeAll()
}

private func cancelWorkspaceGitProbeTask(for key: WorkspaceGitProbeKey) {
workspaceGitProbeTasksByKey.removeValue(forKey: key)?.cancel()
}

private func clearWorkspaceGitProbe(_ key: WorkspaceGitProbeKey) {
removeWorkspaceGitSnapshotRequest(for: key)
workspaceGitProbeStateByKey.removeValue(forKey: key)
workspaceGitCleanIndexSignatureByKey.removeValue(forKey: key)
workspaceGitCleanIndexContentSignatureByKey.removeValue(forKey: key)
Expand Down Expand Up @@ -3002,10 +3176,11 @@ class TabManager: ObservableObject {
}
}

private nonisolated func initialWorkspaceGitMetadataSnapshot(
for directory: String
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
for directory: String,
reader: any WorkspaceGitMetadataReading
) async -> InitialWorkspaceGitMetadataSnapshot {
let metadata = await gitMetadataService.workspaceMetadata(for: directory)
let metadata = await reader.workspaceMetadata(for: directory)
guard metadata.isRepository else {
return InitialWorkspaceGitMetadataSnapshot(
isRepository: false,
Expand Down
Loading
Loading