Skip to content

Commit 0b61e20

Browse files
committed
fix(macos): resolve final quality review findings
1 parent ba17354 commit 0b61e20

13 files changed

Lines changed: 1251 additions & 242 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jobs:
3535
- name: Check generated Xcode project drift
3636
shell: bash
3737
run: |
38+
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts/tests/test_generate_acp_swift.py
3839
scripts/generate-acp-swift.py --check
3940
PATH="$RUNNER_TEMP/bin:$PATH" scripts/generate-macos-project.sh
4041
git diff --exit-code -- macos/Config/Version.xcconfig macos/KitDesktop/Generated macos/KitDesktop.xcodeproj

fixtures/mock-acp-v2.py

Lines changed: 422 additions & 0 deletions
Large diffs are not rendered by default.

fixtures/mock-acp.py

Lines changed: 16 additions & 192 deletions
Large diffs are not rendered by default.

macos/KitDesktop/Models/AppModel.swift

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,28 +135,101 @@ final class AppModel: ObservableObject {
135135
guard allowPersistenceMutation() else { return }
136136
var merged = state.conversations
137137
var indexBySessionID: [String: Int] = [:]
138+
var reservedIndexBySessionID: [String: Int] = [:]
138139
for (index, conversation) in merged.enumerated() where conversation.workspaceID == workspaceID {
139-
if let sessionID = conversation.sessionID { indexBySessionID[sessionID] = index }
140+
if let sessionID = conversation.sessionID, indexBySessionID[sessionID] == nil {
141+
indexBySessionID[sessionID] = index
142+
}
143+
if let controller = controllers[conversation.id] {
144+
reservedIndexBySessionID[controller.reservedSessionID] = index
145+
}
140146
}
147+
148+
var preferredIndexBySessionID: [String: Int] = [:]
141149
for session in catalog {
142-
if let index = indexBySessionID[session.sessionID] {
143-
if let title = session.title { merged[index].title = title }
144-
if let updatedAt = session.updatedAt { merged[index].updatedAt = updatedAt }
150+
let index: Int
151+
if let reserved = reservedIndexBySessionID[session.sessionID] {
152+
index = reserved
153+
merged[index].sessionID = session.sessionID
154+
} else if let existing = indexBySessionID[session.sessionID] {
155+
index = existing
145156
} else {
146157
let timestamp = session.updatedAt ?? Date()
147158
let conversation = Conversation(
148159
workspaceID: workspaceID, title: session.title ?? "Kit session",
149160
sessionID: session.sessionID, createdAt: timestamp, updatedAt: timestamp
150161
)
151-
indexBySessionID[session.sessionID] = merged.count
162+
index = merged.count
152163
merged.append(conversation)
153164
}
165+
preferredIndexBySessionID[session.sessionID] = index
166+
if let title = session.title { merged[index].title = title }
167+
if let updatedAt = session.updatedAt { merged[index].updatedAt = updatedAt }
154168
}
169+
170+
let catalogIDs = Set(catalog.map(\.sessionID))
171+
let preferredIDBySessionID = preferredIndexBySessionID.mapValues { merged[$0].id }
172+
var removedIDs: Set<UUID> = []
173+
var replacements: [UUID: UUID] = [:]
174+
merged = merged.compactMap { conversation in
175+
guard conversation.workspaceID == workspaceID, let sessionID = conversation.sessionID else {
176+
return conversation
177+
}
178+
guard catalogIDs.contains(sessionID), let preferredID = preferredIDBySessionID[sessionID] else {
179+
// A live controller is itself a recovery path and may briefly outrun an
180+
// eventually consistent catalog. Only prune omitted dormant sessions.
181+
guard controllers[conversation.id] == nil else { return conversation }
182+
removedIDs.insert(conversation.id)
183+
return nil
184+
}
185+
guard preferredID == conversation.id else {
186+
removedIDs.insert(conversation.id)
187+
replacements[conversation.id] = preferredID
188+
return nil
189+
}
190+
return conversation
191+
}
192+
removeControllersAndRepairSelection(removedIDs, replacements: replacements)
155193
guard merged != state.conversations else { return }
156194
state.conversations = merged
157195
save()
158196
}
159197

198+
func sessionBecameReady(conversationID: UUID, sessionID: String) {
199+
guard allowPersistenceMutation(),
200+
let preferred = state.conversations.first(where: { $0.id == conversationID }) else { return }
201+
let duplicates = state.conversations.filter {
202+
$0.id != conversationID && $0.workspaceID == preferred.workspaceID && $0.sessionID == sessionID
203+
}
204+
let duplicateIDs = Set(duplicates.map(\.id))
205+
let replacements = Dictionary(uniqueKeysWithValues: duplicates.map { ($0.id, conversationID) })
206+
let catalogMetadata = duplicates.max { $0.updatedAt < $1.updatedAt }
207+
state.conversations.removeAll { duplicateIDs.contains($0.id) }
208+
removeControllersAndRepairSelection(duplicateIDs, replacements: replacements)
209+
guard let index = state.conversations.firstIndex(where: { $0.id == conversationID }) else { return }
210+
state.conversations[index].sessionID = sessionID
211+
if let catalogMetadata {
212+
state.conversations[index].title = catalogMetadata.title
213+
state.conversations[index].createdAt = min(state.conversations[index].createdAt, catalogMetadata.createdAt)
214+
}
215+
state.conversations[index].updatedAt = Date()
216+
save()
217+
}
218+
219+
private func removeControllersAndRepairSelection(_ removedIDs: Set<UUID>, replacements: [UUID: UUID]) {
220+
guard !removedIDs.isEmpty else { return }
221+
for id in removedIDs {
222+
controllers.removeValue(forKey: id)?.close()
223+
activity.removeValue(forKey: id)
224+
}
225+
if let selectedConversationID, removedIDs.contains(selectedConversationID) {
226+
self.selectedConversationID = replacements[selectedConversationID]
227+
}
228+
if let pendingConversationID, removedIDs.contains(pendingConversationID) {
229+
self.pendingConversationID = replacements[pendingConversationID]
230+
}
231+
}
232+
160233
private func refreshSessionCatalog(for workspaceID: UUID) {
161234
guard let catalogLoader, !persistenceIsReadOnly, !isClosing,
162235
let workspace = state.workspaces.first(where: { $0.id == workspaceID }) else { return }
@@ -188,7 +261,7 @@ final class AppModel: ObservableObject {
188261
let controller = controllerFactory(conversation, workspace.path)
189262
controller.onSessionReady = { [weak self] sessionID, _ in
190263
guard let self else { return }
191-
self.updateConversation(id) { item in item.sessionID = sessionID; item.updatedAt = Date() }
264+
self.sessionBecameReady(conversationID: id, sessionID: sessionID)
192265
if self.pendingConversationID == id || self.selectedConversationID == id {
193266
self.pendingConversationID = nil
194267
self.commitSelection(id)

macos/KitDesktop/Models/ConversationController.swift

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ final class ConversationController: ObservableObject {
7878
private var composerTransaction: ComposerTransaction?
7979
private var shuttingDown = false
8080
private var retrying = false
81+
private var clientGeneration: UInt64 = 0
8182
private var startupUpdates: [DesktopUpdate]?
8283
private var expectedRuntimeSessionID: String?
8384
private var rosterPruneWorkItem: DispatchWorkItem?
@@ -97,37 +98,40 @@ final class ConversationController: ObservableObject {
9798
self.client = client
9899
}
99100

101+
var reservedSessionID: String { conversation.sessionID ?? launchSessionID }
102+
100103
func start() { start(force: false) }
101104

102105
func retryIfNeeded() {
103-
guard isRetryable, !retrying else { return }
106+
guard isRetryable, !retrying, !shuttingDown else { return }
104107
retrying = true
105108
isRetryable = false
106109
status = "Reconnecting…"
107-
let previous = client
108-
previous.close(activeTurn: false) { [weak self] in
109-
guard let self else { return }
110-
self.client = previous.replacement()
111-
self.retrying = false
112-
self.start(force: false)
113-
}
110+
replaceClientAfterClosing(force: false)
114111
}
115112

116113
private func start(force: Bool) {
114+
guard !shuttingDown else { return }
117115
status = force ? "Recovering stale session lock…" : "Connecting…"
118116
let persisted = conversation.sessionID
117+
let activeClient = client
118+
let generation = clientGeneration
119119
startupUpdates = persisted == nil ? nil : []
120-
client.onUpdate = { [weak self] update in
121-
guard let self else { return }
120+
activeClient.onUpdate = { [weak self, weak activeClient] update in
121+
guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return }
122122
if self.startupUpdates != nil { self.startupUpdates?.append(update) }
123123
else { self.apply(update) }
124124
}
125-
client.onRuntimeEvent = { [weak self] event in self?.applyRuntime(event) }
126-
client.onDiagnostic = { [weak self] text in
127-
self?.recordDiagnostic(text, updateStatus: !text.hasPrefix("Ignored unsupported desktop update:") && !text.hasPrefix("Ignored malformed desktop update:"))
125+
activeClient.onRuntimeEvent = { [weak self, weak activeClient] event in
126+
guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return }
127+
self.applyRuntime(event)
128128
}
129-
client.onExit = { [weak self] code in
130-
guard let self else { return }
129+
activeClient.onDiagnostic = { [weak self, weak activeClient] text in
130+
guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return }
131+
self.recordDiagnostic(text, updateStatus: !text.hasPrefix("Ignored unsupported desktop update:") && !text.hasPrefix("Ignored malformed desktop update:"))
132+
}
133+
activeClient.onExit = { [weak self, weak activeClient] code in
134+
guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation) else { return }
131135
self.reduceSettlement(.processExit(code))
132136
var roster = self.agentRoster
133137
roster.retireActive(at: Self.nowMilliseconds())
@@ -145,8 +149,8 @@ final class ConversationController: ObservableObject {
145149
reasoningEffort: inheritsConfig ? nil : conversation.reasoningEffort,
146150
force: force
147151
)
148-
client.start(options: options, loading: persisted != nil) { [weak self] result in
149-
guard let self else { return }
152+
activeClient.start(options: options, loading: persisted != nil) { [weak self, weak activeClient] result in
153+
guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return }
150154
switch result {
151155
case .failure(let error):
152156
self.startupUpdates = nil
@@ -732,17 +736,28 @@ final class ConversationController: ObservableObject {
732736
}
733737

734738
private func retryAfterStaleLock() {
735-
guard !retrying else { return }
739+
guard !retrying, !shuttingDown else { return }
736740
retrying = true
741+
replaceClientAfterClosing(force: true)
742+
}
743+
744+
private func replaceClientAfterClosing(force: Bool) {
737745
let previous = client
738-
previous.close(activeTurn: false) { [weak self] in
739-
guard let self else { return }
746+
let generation = clientGeneration
747+
previous.close(activeTurn: false) { [weak self, weak previous] in
748+
guard let self, let previous, !self.shuttingDown,
749+
self.isCurrentClient(previous, generation: generation) else { return }
750+
self.clientGeneration &+= 1
740751
self.client = previous.replacement()
741752
self.retrying = false
742-
self.start(force: true)
753+
self.start(force: force)
743754
}
744755
}
745756

757+
private func isCurrentClient(_ candidate: ACPClient, generation: UInt64) -> Bool {
758+
client === candidate && clientGeneration == generation
759+
}
760+
746761
private static func isStaleLockError(_ error: Error) -> Bool {
747762
guard case ACPClientError.remote(_, let message) = error else { return false }
748763
return message.contains("use --force to override a stale lock")

macos/KitDesktop/Services/ACPClient.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ struct ACPSessionInfo: Equatable {
2020
final class ACPClient {
2121
typealias Dictionary = [String: Any]
2222
static let protocolVersion = 2
23+
private static let activeTurnCancelGrace: TimeInterval = 1
24+
private static let closeRequestTimeout: TimeInterval = 2
25+
private static let terminationGrace: TimeInterval = 1
26+
private static let inheritedPipeDrainTimeout: TimeInterval = 2
2327

2428
struct LaunchOverride {
2529
let executable: URL
@@ -134,7 +138,7 @@ final class ACPClient {
134138
self.closing = true
135139
if activeTurn, let sessionID = self.sessionID {
136140
self.notifyLocked(method: "session/cancel", params: ["sessionId": sessionID])
137-
self.queue.asyncAfter(deadline: .now() + 3) { self.closeSessionLocked() }
141+
self.queue.asyncAfter(deadline: .now() + Self.activeTurnCancelGrace) { self.closeSessionLocked() }
138142
} else { self.closeSessionLocked() }
139143
}
140144
}
@@ -520,14 +524,22 @@ final class ACPClient {
520524
private func closeSessionLocked() {
521525
guard isProcessRunning else { if let exitStatus { processDidExitLocked(exitStatus) }; return }
522526
guard let sessionID else { terminateLocked(); return }
523-
requestLocked(method: "session/close", params: ["sessionId": sessionID], timeout: 10) { _ in self.queue.async { self.terminateLocked() } }
527+
requestLocked(method: "session/close", params: ["sessionId": sessionID], timeout: Self.closeRequestTimeout) { _ in
528+
self.queue.async { self.terminateLocked() }
529+
}
530+
// Request completions are delivered on the main queue. Keep the hard stop on
531+
// the transport queue so a busy UI cannot push process teardown past app exit.
532+
queue.asyncAfter(deadline: .now() + Self.closeRequestTimeout) {
533+
guard self.closing, !self.exited else { return }
534+
self.terminateLocked()
535+
}
524536
}
525537

526538
private func terminateLocked() {
527539
try? inputPipe.fileHandleForWriting.close()
528540
if let group = processGroup { _ = Darwin.kill(-group, SIGTERM) }
529541
else if let pid = processPID, isProcessRunning { _ = Darwin.kill(pid, SIGTERM) }
530-
queue.asyncAfter(deadline: .now() + 2) {
542+
queue.asyncAfter(deadline: .now() + Self.terminationGrace) {
531543
if let group = self.processGroup {
532544
if Darwin.kill(-group, 0) == 0 { _ = Darwin.kill(-group, SIGKILL) }
533545
} else if self.isProcessRunning, let pid = self.processPID { _ = Darwin.kill(pid, SIGKILL) }
@@ -568,8 +580,12 @@ final class ACPClient {
568580
private func processDidExitLocked(_ status: Int32) {
569581
guard exitStatus == nil else { return }
570582
exitStatus = status
583+
// The process leader can crash while descendants keep inherited stdio pipes open.
584+
// Signal the whole group even after the leader has been reaped so those descendants
585+
// cannot keep shutdown or transport completion alive indefinitely.
586+
terminateLocked()
571587
if stdoutClosed && stderrClosed { finalizeExitLocked(status); return }
572-
queue.asyncAfter(deadline: .now() + 5) {
588+
queue.asyncAfter(deadline: .now() + Self.inheritedPipeDrainTimeout) {
573589
guard !self.exited, self.exitStatus != nil else { return }
574590
self.outputPipe.fileHandleForReading.readabilityHandler = nil
575591
self.errorPipe.fileHandleForReading.readabilityHandler = nil

macos/KitDesktop/Services/PersistenceStore.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ final class PersistenceStore {
2727
}
2828

2929
func load() throws -> PersistedAppState {
30-
guard FileManager.default.fileExists(atPath: fileURL.path) else { return PersistedAppState() }
30+
guard FileManager.default.fileExists(atPath: fileURL.path) else {
31+
guard FileManager.default.fileExists(atPath: backupURL.path) else { return PersistedAppState() }
32+
return try decode(Data(contentsOf: backupURL))
33+
}
3134
do { return try decode(Data(contentsOf: fileURL)) }
3235
catch let error as PersistenceError {
3336
if case .unsupportedSchema = error { throw error }
@@ -72,7 +75,12 @@ final class PersistenceStore {
7275
try? FileManager.default.copyItem(at: fileURL, to: quarantine)
7376
if FileManager.default.fileExists(atPath: backupURL.path) {
7477
do { return try decode(Data(contentsOf: backupURL)) }
75-
catch { throw PersistenceError.unreadableState("\(original.localizedDescription); backup: \(error.localizedDescription)") }
78+
catch let error as PersistenceError {
79+
if case .unsupportedSchema = error { throw error }
80+
throw PersistenceError.unreadableState("\(original.localizedDescription); backup: \(error.localizedDescription)")
81+
} catch {
82+
throw PersistenceError.unreadableState("\(original.localizedDescription); backup: \(error.localizedDescription)")
83+
}
7684
}
7785
throw PersistenceError.unreadableState(original.localizedDescription)
7886
}

0 commit comments

Comments
 (0)