-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Modernize concurrency toward Swift 6 (audit + 15-file landable fixes) #4977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
f7b9380
9226206
96ee8d1
ed7834c
6d7564c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1909,7 +1909,9 @@ final class CmuxConfigStore: ObservableObject { | |
| private var resolvedNewWorkspaceCommandCache: CmuxResolvedCommand? | ||
| private var resolvedNewWorkspaceActionCache: CmuxResolvedConfigAction? | ||
| private var parsedConfigCache: [String: ParsedConfigCacheEntry] = [:] | ||
| private var lifetimeCancellables = Set<AnyCancellable>() | ||
| private var trustObservationTask: Task<Void, Never>? | ||
| private var localReattachTask: Task<Void, Never>? | ||
| private var globalReattachTask: Task<Void, Never>? | ||
| private var trackingCancellables = Set<AnyCancellable>() | ||
| private var localFileWatchSource: DispatchSourceFileSystemObject? | ||
| private var localFileDescriptor: Int32 = -1 | ||
|
|
@@ -1946,13 +1948,20 @@ final class CmuxConfigStore: ObservableObject { | |
| self.localConfigPath = localConfigPath | ||
| self.fileWatchingEnabled = startFileWatchers | ||
| self.localConfigSearchDirectory = localConfigPath.map(Self.searchDirectoryForLocalConfigPath(_:)) | ||
| NotificationCenter.default.publisher(for: CmuxActionTrust.didChangeNotification) | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] _ in | ||
| // The store is @MainActor, so this task inherits main-actor isolation and | ||
| // loadAll() runs on main without an explicit dispatch hop. The handle is | ||
| // cancelled in deinit so the observer lives exactly as long as the store. | ||
| trustObservationTask = Task { [weak self] in | ||
| // Map to Void so the non-Sendable `Notification` never crosses into | ||
| // this main-actor task; only the change signal is needed, not the value. | ||
| let signals = NotificationCenter.default.notifications( | ||
| named: CmuxActionTrust.didChangeNotification | ||
| ).map { _ in () } | ||
| for await _ in signals { | ||
| guard let self else { return } | ||
| self.loadAll() | ||
| } | ||
| .store(in: &lifetimeCancellables) | ||
| } | ||
| if startFileWatchers { | ||
| if localConfigPath != nil { | ||
| startLocalFileWatcher() | ||
|
|
@@ -1962,6 +1971,9 @@ final class CmuxConfigStore: ObservableObject { | |
| } | ||
|
|
||
| deinit { | ||
| trustObservationTask?.cancel() | ||
| localReattachTask?.cancel() | ||
| globalReattachTask?.cancel() | ||
| localFileWatchSource?.cancel() | ||
| for source in localHookFileWatchSources.values { | ||
| source.cancel() | ||
|
|
@@ -2961,7 +2973,7 @@ final class CmuxConfigStore: ObservableObject { | |
| DispatchQueue.main.async { | ||
| self.stopLocalFileWatcher() | ||
| self.loadAll() | ||
| self.scheduleLocalReattach(attempt: 1) | ||
| self.scheduleLocalReattach() | ||
| } | ||
| } else { | ||
| DispatchQueue.main.async { | ||
|
|
@@ -3102,18 +3114,20 @@ final class CmuxConfigStore: ObservableObject { | |
| startLocalFileWatcher() | ||
| } | ||
|
|
||
| private func scheduleLocalReattach(attempt: Int) { | ||
| guard attempt <= Self.maxReattachAttempts else { return } | ||
| watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in | ||
| guard let self else { return } | ||
| DispatchQueue.main.async { | ||
| guard let path = self.localConfigPath else { return } | ||
| if FileManager.default.fileExists(atPath: path) { | ||
| self.loadAll() | ||
| self.startLocalFileWatcher() | ||
| } else { | ||
| self.startLocalDirectoryWatcher() | ||
| } | ||
| private func scheduleLocalReattach() { | ||
| // A deleted config has no inode to attach a DispatchSource to, so this is a | ||
| // genuine poll for the file's reappearance, not a timing hack. A superseding | ||
| // event or deinit cancels the pending retry via the stored handle. | ||
| localReattachTask?.cancel() | ||
| localReattachTask = Task { @MainActor [weak self] in | ||
| try? await Task.sleep(for: .seconds(Self.reattachDelay)) | ||
| if Task.isCancelled { return } | ||
| guard let self, let path = self.localConfigPath else { return } | ||
| if FileManager.default.fileExists(atPath: path) { | ||
| self.loadAll() | ||
| self.startLocalFileWatcher() | ||
| } else { | ||
| self.startLocalDirectoryWatcher() | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
3102
to
3119
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both 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! |
||
|
|
@@ -3155,7 +3169,7 @@ final class CmuxConfigStore: ObservableObject { | |
| DispatchQueue.main.async { | ||
| self.stopGlobalFileWatcher() | ||
| self.loadAll() | ||
| self.scheduleGlobalReattach(attempt: 1) | ||
| self.scheduleGlobalReattach() | ||
| } | ||
| } else { | ||
| DispatchQueue.main.async { | ||
|
|
@@ -3172,21 +3186,24 @@ final class CmuxConfigStore: ObservableObject { | |
| globalFileWatchSource = source | ||
| } | ||
|
|
||
| private func scheduleGlobalReattach(attempt: Int) { | ||
| guard attempt <= Self.maxReattachAttempts else { | ||
| startGlobalDirectoryWatcher() | ||
| return | ||
| } | ||
| watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in | ||
| guard let self else { return } | ||
| DispatchQueue.main.async { | ||
| private func scheduleGlobalReattach() { | ||
| // A deleted config has no inode to watch, so poll for reappearance with an | ||
| // explicit bounded retry count instead of recursing through the call stack. | ||
| // The stored handle lets a superseding event or deinit cancel the loop. | ||
| globalReattachTask?.cancel() | ||
| globalReattachTask = Task { @MainActor [weak self] in | ||
| for _ in 0..<Self.maxReattachAttempts { | ||
| try? await Task.sleep(for: .seconds(Self.reattachDelay)) | ||
| if Task.isCancelled { return } | ||
| guard let self else { return } | ||
| if FileManager.default.fileExists(atPath: self.globalConfigPath) { | ||
| self.loadAll() | ||
| self.startGlobalFileWatcher() | ||
| } else { | ||
| self.scheduleGlobalReattach(attempt: attempt + 1) | ||
| return | ||
| } | ||
| } | ||
| guard let self else { return } | ||
| self.startGlobalDirectoryWatcher() | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading stdout to EOF then stderr to EOF is safe only when the child writes < 64 KB to each pipe independently. If the child fills its stderr pipe buffer (≥ 64 KB) while the main thread is blocked inside
readDataToEndOfFileOrEmpty(stdout), the child stalls writing to stderr, never closes stdout, and both sides hang indefinitely. The previous concurrentreadabilityHandlerapproach on both pipes explicitly avoided this class of deadlock.A safe synchronous alternative is to drain both descriptors on two concurrent threads (or use
Thread.detachNewThreadfor each) and join them before callingwaitUntilExit(). The existingstartDraining(_:into:group:)+outputGroup.wait()pattern was already correct for this; the only actual fix needed there was replacingNSLock-backedPrivilegedCommandOutputBufferwithOSAllocatedUnfairLock.