Skip to content

Commit 6a7942c

Browse files
authored
perf: Swift 6.2 concurrency and performance optimizations (farouqaldori#48)
* refactor(perf): adopt Swift 6.2 concurrency and performance patterns - Fix ConversationParser actor starvation by extracting file I/O into nonisolated static methods, preventing actor lock during disk ops - Add swift-subprocess package for async process execution, replacing Foundation.Process + DispatchQueue boilerplate in ProcessExecutor - Optimize HookSocketServer buffer with withUnsafeTemporaryAllocation for stack allocation instead of per-read heap allocation - Use lazy split() in incremental parsing to avoid full array allocation - Move NotchHeaderView animation arrays to static properties * perf: implement Swift 6.2 performance optimizations - Cache LCS computation in SimpleDiffView to avoid redundant calculations - Use space-optimized LCS algorithm with UInt8 direction matrix - Add ProcessTree struct with O(1) parent→children index for descendant lookup - Replace components(separatedBy:) with split() for efficient Substring handling - Add reserveCapacity() calls for predictable array sizes - Unify ClaudeSessionMonitor Task bridging to reduce executor hops - Document @Concurrent candidates in ProcessExecutor for Swift 6.2 adoption * perf: add Swift 6.2 concurrency optimizations - Enable nonisolated(nonsending) by default via compiler flag - Enable global concurrency feature for @Concurrent attribute support - Add @Concurrent to ProcessExecutor async methods for explicit concurrent execution - Defer String allocation in ConversationParser incremental parsing hot path * Address review comments from PR farouqaldori#48 - Fix SimpleDiffView computed property recomputation: cachedDiffResult was a computed property, causing the expensive LCS algorithm to run multiple times per render pass. Now computed once at the start of body. - Fix misleading "off-actor" comment in ConversationParser: nonisolated static methods called synchronously from within an actor still execute on the actor's executor. Updated documentation to accurately describe the behavior. - Fix processIncrementalContent returning all messages on no-op reads: when there's no new content, the function now returns an empty array instead of state.messages, which would incorrectly report all messages as "new" in the IncrementalParseResult.newMessages field.
1 parent 778cded commit 6a7942c

9 files changed

Lines changed: 493 additions & 310 deletions

File tree

ClaudeIsland.xcodeproj/project.pbxproj

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/* Begin PBXBuildFile section */
1010
FD0F42E82EE7ACA500980302 /* OcclusionKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD0F42E92EE7ACA500980302 /* OcclusionKit */; };
11+
FD0F42EB2EE7ACB500980302 /* Subprocess in Frameworks */ = {isa = PBXBuildFile; productRef = FD0F42EC2EE7ACB500980302 /* Subprocess */; };
1112
FDA49F7D2EE11E3C00F9612E /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = FD33FD722EDF32D7002A6548 /* Markdown */; };
1213
FDA49F7E2EE11E3C00F9612E /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = FDA49F7F2EE11E3C00F9612E /* Sparkle */; };
1314
/* End PBXBuildFile section */
@@ -43,6 +44,7 @@
4344
buildActionMask = 2147483647;
4445
files = (
4546
FD0F42E82EE7ACA500980302 /* OcclusionKit in Frameworks */,
47+
FD0F42EB2EE7ACB500980302 /* Subprocess in Frameworks */,
4648
FDA49F7D2EE11E3C00F9612E /* Markdown in Frameworks */,
4749
FDA49F7E2EE11E3C00F9612E /* Sparkle in Frameworks */,
4850
);
@@ -90,6 +92,7 @@
9092
FD33FD722EDF32D7002A6548 /* Markdown */,
9193
FDA49F7F2EE11E3C00F9612E /* Sparkle */,
9294
FD0F42E92EE7ACA500980302 /* OcclusionKit */,
95+
FD0F42EC2EE7ACB500980302 /* Subprocess */,
9396
);
9497
productName = ClaudeIsland;
9598
productReference = FD33FD5D2EDF32D7002A6548 /* Claude Island.app */;
@@ -123,6 +126,7 @@
123126
FD33FD712EDF32D7002A6548 /* XCRemoteSwiftPackageReference "swift-markdown" */,
124127
FDA49F802EE11E3C00F9612E /* XCRemoteSwiftPackageReference "Sparkle" */,
125128
FD0F42EA2EE7ACA500980302 /* XCRemoteSwiftPackageReference "OcclusionKit" */,
129+
FD0F42ED2EE7ACB500980302 /* XCRemoteSwiftPackageReference "swift-subprocess" */,
126130
);
127131
preferredProjectObjectVersion = 77;
128132
productRefGroup = FD33FD5E2EDF32D7002A6548 /* Products */;
@@ -305,8 +309,10 @@
305309
SWIFT_APPROACHABLE_CONCURRENCY = YES;
306310
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
307311
SWIFT_EMIT_LOC_STRINGS = YES;
308-
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
309312
SWIFT_STRICT_CONCURRENCY = complete;
313+
SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES;
314+
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
315+
SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES;
310316
SWIFT_VERSION = 6;
311317
};
312318
name = Debug;
@@ -341,8 +347,10 @@
341347
SWIFT_APPROACHABLE_CONCURRENCY = YES;
342348
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
343349
SWIFT_EMIT_LOC_STRINGS = YES;
344-
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
345350
SWIFT_STRICT_CONCURRENCY = complete;
351+
SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES;
352+
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
353+
SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = YES;
346354
SWIFT_VERSION = 6;
347355
};
348356
name = Release;
@@ -379,6 +387,14 @@
379387
kind = branch;
380388
};
381389
};
390+
FD0F42ED2EE7ACB500980302 /* XCRemoteSwiftPackageReference "swift-subprocess" */ = {
391+
isa = XCRemoteSwiftPackageReference;
392+
repositoryURL = "https://github.com/swiftlang/swift-subprocess";
393+
requirement = {
394+
kind = upToNextMajorVersion;
395+
minimumVersion = 0.2.0;
396+
};
397+
};
382398
FD33FD712EDF32D7002A6548 /* XCRemoteSwiftPackageReference "swift-markdown" */ = {
383399
isa = XCRemoteSwiftPackageReference;
384400
repositoryURL = "https://github.com/swiftlang/swift-markdown";
@@ -403,6 +419,11 @@
403419
package = FD0F42EA2EE7ACA500980302 /* XCRemoteSwiftPackageReference "OcclusionKit" */;
404420
productName = OcclusionKit;
405421
};
422+
FD0F42EC2EE7ACB500980302 /* Subprocess */ = {
423+
isa = XCSwiftPackageProductDependency;
424+
package = FD0F42ED2EE7ACB500980302 /* XCRemoteSwiftPackageReference "swift-subprocess" */;
425+
productName = Subprocess;
426+
};
406427
FD33FD722EDF32D7002A6548 /* Markdown */ = {
407428
isa = XCSwiftPackageProductDependency;
408429
package = FD33FD712EDF32D7002A6548 /* XCRemoteSwiftPackageReference "swift-markdown" */;

ClaudeIsland/Services/Hooks/HookSocketServer.swift

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -632,24 +632,28 @@ final class HookSocketServer: @unchecked Sendable { // swiftlint:disable:this ty
632632

633633
private nonisolated func readClientData(clientSocket: Int32) -> Data? {
634634
var allData = Data()
635-
var buffer = [UInt8](repeating: 0, count: 131_072)
636635
var pollFd = pollfd(fd: clientSocket, events: Int16(POLLIN), revents: 0)
637636

638637
let startTime = Date()
639-
while Date().timeIntervalSince(startTime) < 0.5 {
640-
let pollResult = poll(&pollFd, 1, 50)
641-
642-
if pollResult > 0 && (pollFd.revents & Int16(POLLIN)) != 0 {
643-
let bytesRead = read(clientSocket, &buffer, buffer.count)
644-
if bytesRead > 0 {
645-
allData.append(contentsOf: buffer[0 ..< bytesRead])
646-
} else if bytesRead == 0 || (errno != EAGAIN && errno != EWOULDBLOCK) {
638+
// Use stack allocation for buffer to avoid heap allocation per read
639+
withUnsafeTemporaryAllocation(of: UInt8.self, capacity: 131_072) { buffer in
640+
guard let baseAddress = buffer.baseAddress else { return }
641+
642+
while Date().timeIntervalSince(startTime) < 0.5 {
643+
let pollResult = poll(&pollFd, 1, 50)
644+
645+
if pollResult > 0 && (pollFd.revents & Int16(POLLIN)) != 0 {
646+
let bytesRead = read(clientSocket, baseAddress, buffer.count)
647+
if bytesRead > 0 {
648+
allData.append(baseAddress, count: bytesRead)
649+
} else if bytesRead == 0 || (errno != EAGAIN && errno != EWOULDBLOCK) {
650+
break
651+
}
652+
} else if pollResult == 0 && !allData.isEmpty {
653+
break
654+
} else if pollResult != 0 {
647655
break
648656
}
649-
} else if pollResult == 0 && !allData.isEmpty {
650-
break
651-
} else if pollResult != 0 {
652-
break
653657
}
654658
}
655659

ClaudeIsland/Services/Session/ClaudeSessionMonitor.swift

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -43,52 +43,14 @@ final class ClaudeSessionMonitor {
4343
HookSocketServer.shared.start(
4444
onEvent: { [weak self] event in
4545
// HookSocketServer calls this callback on its internal socket queue.
46-
// We must hop to MainActor before accessing self (a @MainActor type).
46+
// Single MainActor hop handles all event processing.
4747
Task { @MainActor [weak self] in
48-
guard let self else { return }
49-
50-
let task = Task {
51-
await SessionStore.shared.process(.hookReceived(event))
52-
}
53-
self.trackTask(task)
54-
55-
if event.sessionPhase == .processing {
56-
let watchTask = Task { @MainActor in
57-
InterruptWatcherManager.shared.startWatching(
58-
sessionID: event.sessionID,
59-
cwd: event.cwd
60-
)
61-
}
62-
self.trackTask(watchTask)
63-
}
64-
65-
if event.status == "ended" {
66-
let stopTask = Task { @MainActor in
67-
InterruptWatcherManager.shared.stopWatching(sessionID: event.sessionID)
68-
}
69-
self.trackTask(stopTask)
70-
}
71-
72-
if event.event == "Stop" {
73-
HookSocketServer.shared.cancelPendingPermissions(sessionID: event.sessionID)
74-
}
75-
76-
if event.event == "PostToolUse", let toolUseID = event.toolUseID {
77-
HookSocketServer.shared.cancelPendingPermission(toolUseID: toolUseID)
78-
}
48+
await self?.handleHookEvent(event)
7949
}
8050
},
8151
onPermissionFailure: { [weak self] sessionID, toolUseID in
82-
// Same as above - hop to MainActor before accessing self
8352
Task { @MainActor [weak self] in
84-
guard let self else { return }
85-
86-
let task = Task {
87-
await SessionStore.shared.process(
88-
.permissionSocketFailed(sessionID: sessionID, toolUseID: toolUseID)
89-
)
90-
}
91-
self.trackTask(task)
53+
await self?.handlePermissionFailure(sessionID: sessionID, toolUseID: toolUseID)
9254
}
9355
}
9456
)
@@ -175,6 +137,46 @@ final class ClaudeSessionMonitor {
175137
/// Uses Mutex for thread-safe access per Swift 6 patterns
176138
@ObservationIgnored private let activeTasks = Mutex<[UUID: Task<Void, Never>]>([:])
177139

140+
/// Handle hook event - unified async handler to reduce executor hops
141+
private func handleHookEvent(_ event: HookEvent) async {
142+
// Process the hook event (hops to SessionStore actor)
143+
let task = Task {
144+
await SessionStore.shared.process(.hookReceived(event))
145+
}
146+
self.trackTask(task)
147+
148+
// Start/stop interrupt watcher (already on MainActor - no Task needed)
149+
if event.sessionPhase == .processing {
150+
InterruptWatcherManager.shared.startWatching(
151+
sessionID: event.sessionID,
152+
cwd: event.cwd
153+
)
154+
}
155+
156+
if event.status == "ended" {
157+
InterruptWatcherManager.shared.stopWatching(sessionID: event.sessionID)
158+
}
159+
160+
// Cancel pending permissions (nonisolated - no hop needed)
161+
if event.event == "Stop" {
162+
HookSocketServer.shared.cancelPendingPermissions(sessionID: event.sessionID)
163+
}
164+
165+
if event.event == "PostToolUse", let toolUseID = event.toolUseID {
166+
HookSocketServer.shared.cancelPendingPermission(toolUseID: toolUseID)
167+
}
168+
}
169+
170+
/// Handle permission socket failure - unified async handler
171+
private func handlePermissionFailure(sessionID: String, toolUseID: String) async {
172+
let task = Task {
173+
await SessionStore.shared.process(
174+
.permissionSocketFailed(sessionID: sessionID, toolUseID: toolUseID)
175+
)
176+
}
177+
self.trackTask(task)
178+
}
179+
178180
/// Track a task for cancellation on stop
179181
private func trackTask(_ task: Task<Void, Never>) {
180182
let id = UUID()

0 commit comments

Comments
 (0)