Skip to content

Fix concurrency bugs, DDC reliability, crashes, and memory leaks - #1874

Open
MyronKoch wants to merge 2 commits into
MonitorControl:mainfrom
MyronKoch:fix-concurrency-safety
Open

MyronKoch wants to merge 2 commits into
MonitorControl:mainfrom
MyronKoch:fix-concurrency-safety

Conversation

@MyronKoch

@MyronKoch MyronKoch commented May 29, 2026 •

Copy link
Copy Markdown

Summary

Fix pass addressing thread safety, DDC communication reliability, crash-inducing edge cases, and resource leaks. Rebased onto current main; review feedback addressed in a follow-up commit.

Thread Safety

  • Fix swBrightnessSemaphore misuse causing race conditions in software brightness
  • Signal semaphore before async dispatch rather than holding it across the async boundary
  • Keep gamma table writes off the main thread in the smooth brightness loop
  • Wrap shade alpha updates in DispatchQueue.main.async for AppKit safety
  • Move DDC probe work off main thread with a staleness guard
  • Defer gamma interference NSAlert to the next run loop

DDC Reliability

  • Only persist DDC write state after confirming every attempted hardware write succeeded
  • Clear stale arm64avService handles before rematching on wake/reconfig
  • Validate DDC/CI reply header fields before accepting read values
  • Cap retry attempts at 30 to bound startup on unresponsive displays, preserving the prior attempt count underneath the cap
  • Guard division by zero in DDC value normalization

Crash Fixes

  • Replace currentDisplay! force-unwrap in MenuHandler (crashes when the pointer is between screens)
  • Handle CGGetDisplayTransferByTable failure safely
  • Guard zero defaultGammaTablePeak in getSwBrightness

Cleanup

  • Remove shadeGrave memory leak
  • Remove unused sortDisplays()
  • Remove duplicate audioSpeakerVolume default assignment
  • Remove tautological self-comparisons in SliderHandler
  • Migrate login item registration and status read to SMAppService on macOS 13+, legacy path retained below
  • Use kIOMainPortDefault in Arm64DDC behind an availability check. Other call sites stay on kIOMasterPortDefault, since the deployment target is 10.14
  • Replace direct plist read with UserDefaults.globalDomain via UserDefaults.persistentDomain

Testing

Tested on an M3 Max with four displays (built-in plus three external TVs over HDMI and DisplayLink). Verified smooth brightness, sleep/wake recovery, and slider responsiveness. Surgical fixes only, no new features and no UI changes.

Note: a clean checkout of main currently fails to build on recent Xcode because MACOSX_DEPLOYMENT_TARGET is 10.14 while Xcode floors at 12.0. That is pre-existing and untouched here; builds were verified with an override.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR is a broad cleanup/hardening pass for MonitorControl that addresses thread-safety hazards in software brightness/gamma handling, crash-prone force-unwraps, DDC read/write reliability, and several memory/code-hygiene issues, while modernizing a few deprecated APIs.

Changes:

  • Thread/concurrency hardening for software brightness, shade updates, gamma table writes, and the configure pipeline (with configureID/reconfigureID/sleepID staleness guards moving DDC setup off the main thread).
  • DDC reliability fixes: persist write state only after success, validate Arm64 DDC reply header, cap retries at 30, clear stale arm64avService handles before rematching, guard divide-by-zero in DDC normalization.
  • Removals/replacements: dead sortDisplays() and shadeGrave leak gone, tautological self.percentageBox == self.percentageBox checks removed, duplicate audio default removed, SMLoginItemSetEnabled→SMAppService and kIOMasterPortDefault→kIOMainPortDefault (one site), getSystemSettings switched from direct plist read to UserDefaults.persistentDomain.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
MonitorControl/Support/SliderHandler.swift Drops two tautological self.percentageBox == self.percentageBox guards.
MonitorControl/Support/MenuHandler.swift Replaces currentDisplay! force-unwrap with optional-aware filtering for "relevant" sliders.
MonitorControl/Support/DisplayManager.swift Removes shadeGrave leak and dead sortDisplays(); clears stale arm64 service handles before rematching.
MonitorControl/Support/Arm64DDC.swift Validates DDC reply header, caps retries at 30, switches to kIOMainPortDefault on macOS 12+.
MonitorControl/Support/AppDelegate.swift Adds configureID staleness guard and moves setupOtherDisplays to a background queue; migrates login-item registration to SMAppService; rewrites getSystemSettings via UserDefaults.
MonitorControl/Model/OtherDisplay.swift Only persists writeDDCLastSavedValue/isTouched after the DDC write actually succeeds; guards divide-by-zero in DDC value (de)normalization; drops duplicate audio default.
MonitorControl/Model/Display.swift Handles CGGetDisplayTransferByTable failure, guards zero defaultGammaTablePeak, signals swBrightnessSemaphore before async smooth dispatch, wraps shade/gamma updates with DispatchQueue.main.async/sync, defers gamma-interference alert.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread MonitorControl/Model/Display.swift Outdated
Comment on lines +244 to +246
DispatchQueue.main.sync {
CGSetDisplayTransferByTable(self.identifier, self.defaultGammaTableSampleCount, gammaTableRed, gammaTableGreen, gammaTableBlue)
}
Comment on lines +342 to 344
func getSystemSettings() -> [String: Any]? {
UserDefaults.standard.persistentDomain(forName: ".GlobalPreferences")
}
Comment thread MonitorControl/Support/Arm64DDC.swift Outdated
Comment on lines +103 to +104
let attempts = max(1, Int(min(numOfRetryAttemps ?? 4, UInt8(30))))
for _ in 0 ..< attempts {
Comment on lines +326 to +339
if #available(macOS 13.0, *) {
let service = SMAppService.loginItem(identifier: identifier)
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
} catch {
os_log("Unable to update login item registration: %{public}@", type: .error, error.localizedDescription)
}
} else {
SMLoginItemSetEnabled(identifier as CFString, enabled)
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Comment thread MonitorControl/Support/Arm64DDC.swift Outdated
Comment on lines +103 to +104
let attempts = max(1, Int(min(numOfRetryAttemps ?? 4, UInt8(30))))
for _ in 0 ..< attempts {
Comment on lines 229 to +251
if smooth {
self.swBrightnessSemaphore.signal()
DispatchQueue.global(qos: .userInteractive).async {
for transientValue in stride(from: currentValue, to: newValue, by: 0.005 * (currentValue > newValue ? -1 : 1)) {
guard app.reconfigureID == 0 else {
self.swBrightnessSemaphore.signal()
return
}
if self.isVirtual || self.readPrefAsBool(key: .avoidGamma) {
_ = DisplayManager.shared.setShadeAlpha(value: 1 - transientValue, displayID: DisplayManager.resolveEffectiveDisplayID(self.identifier))
DispatchQueue.main.async {
_ = DisplayManager.shared.setShadeAlpha(value: 1 - transientValue, displayID: DisplayManager.resolveEffectiveDisplayID(self.identifier))
}
} else {
let gammaTableRed = self.defaultGammaTableRed.map { $0 * transientValue }
let gammaTableGreen = self.defaultGammaTableGreen.map { $0 * transientValue }
let gammaTableBlue = self.defaultGammaTableBlue.map { $0 * transientValue }
CGSetDisplayTransferByTable(self.identifier, self.defaultGammaTableSampleCount, gammaTableRed, gammaTableGreen, gammaTableBlue)
DispatchQueue.main.sync {
CGSetDisplayTransferByTable(self.identifier, self.defaultGammaTableSampleCount, gammaTableRed, gammaTableGreen, gammaTableBlue)
}
}
Thread.sleep(forTimeInterval: 0.001) // Let's make things quick if not performed in the background
}
}
return true
Comment thread MonitorControl/Model/Display.swift Outdated
Comment on lines +244 to +246
DispatchQueue.main.sync {
CGSetDisplayTransferByTable(self.identifier, self.defaultGammaTableSampleCount, gammaTableRed, gammaTableGreen, gammaTableBlue)
}
Comment on lines +325 to +339
let identifier = "\(Bundle.main.bundleIdentifier!)Helper"
if #available(macOS 13.0, *) {
let service = SMAppService.loginItem(identifier: identifier)
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
} catch {
os_log("Unable to update login item registration: %{public}@", type: .error, error.localizedDescription)
}
} else {
SMLoginItemSetEnabled(identifier as CFString, enabled)
}
Comment on lines +239 to +245
let masterPort: mach_port_t
if #available(macOS 12.0, *) {
masterPort = kIOMainPortDefault
} else {
masterPort = kIOMasterPortDefault
}
let ioregRoot: io_registry_entry_t = IORegistryGetRootEntry(masterPort)
Comment on lines +403 to 426
var writeAttempted = false
var writeSucceeded = true
for controlCode in controlCodes {
if Arm64DDC.isArm64 {
if self.arm64ddc {
_ = Arm64DDC.write(service: self.arm64avService, command: controlCode, value: value)
writeAttempted = true
writeSucceeded = Arm64DDC.write(service: self.arm64avService, command: controlCode, value: value)
}
} else {
_ = self.ddc?.write(command: controlCode, value: value, errorRecoveryWaitTime: 2000) ?? false
if self.ddc != nil {
writeAttempted = true
writeSucceeded = self.ddc?.write(command: controlCode, value: value, errorRecoveryWaitTime: 2000) ?? false
}
}
if writeAttempted, !writeSucceeded {
break
}
}
if writeAttempted, writeSucceeded {
self.writeDDCQueue.async(flags: .barrier) {
self.writeDDCLastSavedValue[command] = value
self.savePref(true, key: PrefKey.isTouched, for: command)
}
}
@waydabber waydabber added the AI contribution / fork recommendation Unplanned PR (typically AI) with improvements which may be of interest to the community label Jun 14, 2026
@waydabber

Copy link
Copy Markdown
Member

The black screen issue is usually caused by a malformed custom color profile and the app using color table adjustments. It's a longstanding macOS bug - this mix results in a black screen (entirely undetectable by the app as the color table looks fine, just nothing is visible on screen 🤣).

waydabber/BetterDisplay#5802

Thread safety:
- Fix swBrightnessSemaphore misuse causing races in software brightness
- Signal semaphore before async dispatch to prevent deadlock with polling job
- Wrap CGSetDisplayTransferByTable in DispatchQueue.main.sync for thread safety
- Wrap shade alpha updates in DispatchQueue.main.async for AppKit safety
- Move DDC probe work off main thread with configureID staleness guard
- Defer gamma interference alert to next run loop iteration

DDC reliability:
- Only persist DDC write state after confirming hardware write succeeded
- Clear stale arm64avService handles before rematching on wake/reconfig
- Validate DDC/CI reply header fields before accepting read values
- Cap DDC retry attempts at 30 to prevent multi-minute startup hangs
- Guard against division by zero in DDC value normalization

Crash fixes:
- Replace force-unwrap on currentDisplay in MenuHandler
- Handle gamma table acquisition failure safely
- Guard against zero defaultGammaTablePeak in getSwBrightness

Cleanup:
- Remove shadeGrave memory leak (closed windows retained forever)
- Remove dead sortDisplays() with stray Turkish comments
- Remove duplicate audioSpeakerVolume DDC default assignment
- Remove tautological self-comparisons in SliderHandler
- Replace deprecated SMLoginItemSetEnabled with SMAppService on macOS 13+
- Replace deprecated kIOMasterPortDefault with kIOMainPortDefault on macOS 12+
- Replace direct plist read with UserDefaults.persistentDomain API
- Drop DispatchQueue.main.sync around CGSetDisplayTransferByTable in the
  smooth-brightness loop. CoreGraphics does not require the main thread
  here, and hopping per gamma step (~200 iterations) both stalled the
  animation and contended with main-thread UI work.
- Address the global preferences domain via UserDefaults.globalDomain
  instead of the literal ".GlobalPreferences", which is a filename and
  not a CFPreferences application ID. The old form returned nil, so the
  volume-changed beep silently never played.
- Restore the original DDC attempt count. The retry cap regressed the
  default from 5 attempts to 4 (and minimal polling from 2 to 1); it is
  now min(attempts + 1, 30), preserving prior behaviour under the cap.
- Migrate the login-item status read in MainPrefsViewController to
  SMAppService.loginItem(_:).status on macOS 13+. The writer had moved to
  SMAppService while the reader still used SMCopyAllJobDictionaries, so
  the Start at login checkbox showed stale state.
- Track DDC write success across every attempted control code rather than
  letting the last iteration win, so writeDDCLastSavedValue and isTouched
  are persisted only when all attempted writes actually succeeded.

kIOMainPortDefault is intentionally changed only in Arm64DDC, where it is
guarded by an availability check. The remaining call sites stay on
kIOMasterPortDefault because the project's deployment target predates
macOS 12.
@MyronKoch
MyronKoch force-pushed the fix-concurrency-safety branch from ab7c18f to 5afd215 Compare September 21, 2026 17:35
@MyronKoch

Copy link
Copy Markdown
Author

Rebased onto current main (now at 5ce1a25) and worked through the review findings. Summary of what changed:

Fixed

Finding Resolution
DispatchQueue.main.sync around CGSetDisplayTransferByTable stalls the ramp and contends with main-thread work Wrapper removed entirely. CoreGraphics does not require the main thread here, so the gamma write stays on the background queue.
".GlobalPreferences" is a filename, not a CFPreferences application ID Now UserDefaults.globalDomain. The previous form returned nil, so playVolumeChangedSound() silently never played the beep even when the user had it enabled.
Retry cap regressed the default from 5 attempts to 4, and minimal polling from 2 to 1 Now min(Int(numOfRetryAttemps ?? 4) + 1, 30), which preserves prior behaviour underneath the cap.
SMCopyAllJobDictionaries read path not migrated alongside the SMAppService writer MainPrefsViewController.populateSettings now reads SMAppService.loginItem(_:).status on macOS 13+, with the legacy path retained below it. Good catch, the checkbox would have shown stale state.
writeSucceeded reflects only the last attempted control code Replaced with allSucceeded, ANDed across every attempted write, so state is persisted only when all attempted writes succeeded.

Narrowed rather than expanded

kIOMainPortDefault is intentionally changed only in Arm64DDC, where it sits behind an availability check. The remaining call sites in IntelDDC, NSScreen+Extension and DisplaysPrefsViewController stay on kIOMasterPortDefault, because the deployment target is 10.14 and switching them would mean adding availability guards in four more places for no functional gain. The PR description overstated that scope and I have corrected it.

Left open deliberately

Signalling swBrightnessSemaphore before the async dispatch does remove serialisation between overlapping smooth ramps, as the review notes. That change was originally a mitigation for a deadlock created by the main.sync hop, and removing that hop retires the original deadlock vector. Holding the semaphore across the async boundary instead would restore serialisation, but it parks the main thread for the duration of a ramp, roughly 200 iterations at 1 ms, whenever getSwBrightness() is called concurrently. The separate flag guard suggested in the review looks like the right answer, but it is a behavioural change rather than a fix, so I have not decided it unilaterally. Happy to add it here if you would prefer that over the current shape.

Unrelated build note

A clean checkout of main does not build on current Xcode: MACOSX_DEPLOYMENT_TARGET is still 10.14 while Xcode now floors at 12.0. I verified this on untouched main, so it is not introduced by this PR, and I have not touched it here. Flagging in case it is not already known.

@MyronKoch

Copy link
Copy Markdown
Author

Thanks, that is genuinely useful, and it sits close to the code this PR touches. I read through the linked discussion.

To be clear about scope: nothing here changes whether gamma table control is used, only how the writes are sequenced and how failures are handled. The avoidGamma per-display preference, and the existing interference prompt that offers to set it across all displays, already give users the escape hatch you describe as the second workaround.

One change is adjacent to your failure mode without actually addressing it. swUpdateDefaultGammaTable() previously ignored the return value of CGGetDisplayTransferByTable, so a failed read left defaultGammaTablePeak at 0, and getSwBrightness() then divided by it. This PR handles the failed read and guards the zero peak. That covers a degenerate read. It does nothing for the case you are describing, where macOS reports a perfectly valid table and the panel goes black anyway. From your description I do not think anything at the app layer could detect that, since the app is being told everything is fine.

One question, since you have far more history with this than I do: given the profile interaction, is there an argument for defaulting avoidGamma on for displays running a non-factory ICC profile? That looks like the only lever available from this side, but it is a product decision rather than a bug fix, so I have left it well alone in this PR.

@waydabber

Copy link
Copy Markdown
Member

It may be a bit of an overkill, as not all custom profiles are inherently wrong - the problem manifests itself with some third party app created ones. But maybe a warning could be added about it. Alternatively, defaulting to avoidGamma may make sense until it's explicitly turned off, but since the user can switch profiles any time, this is not a very safe approach if the goal is to deal with this, so instead of defaulting to something, maybe an explicit switch would be needed that enables gamma table adjustment with a custom profile.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI contribution / fork recommendation Unplanned PR (typically AI) with improvements which may be of interest to the community

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants