fix(display): 缓存 DDC 服务匹配并隔离逐屏超时

- 按连接缓存 IORegistry 匹配结果,屏幕配置变化、唤醒和手动重新检测时清空,轮询不再每 5 秒全量遍历注册表
- DCPAVServiceProxy 按遍历顺序归属后,再用服务自身 EDID 与 CG 显示器的厂商、型号、序列号比对,排除其他显示器的服务
- 每台显示器读取使用独立取消令牌,一台超时不再导致后续显示器跳过
This commit is contained in:
ysicing authored and 缘生 committed 2026-10-04 21:52:14 +08:00
1 parent 3c30a52077
commit 1bd75ba0db
7 files changed
+153 -11

No files matched your search

+8 -3
View File
@@ -615,8 +615,12 @@ submits one target value. Wake and reconfiguration settling gates also block man
build. The information view remains usable if these entry points are unavailable. These non-public
control calls are not a Store-target compatibility claim; a future sandboxed target must exclude this
backend. Services are matched by the CoreDisplay registry location, not by a guessed model name;
ambiguous/missing matches disable controls. Device signatures and controller generations reject
stale requests after reconnects. No brightness/volume/contrast values are automatically restored.
ambiguous/missing matches disable controls. `DCPAVServiceProxy` is not a descendant of its
framebuffer, so it is paired by registry order and then rejected if the service's own EDID
(vendor, product, serial) disagrees with the CG display; an unreadable EDID keeps the location match.
Match results, including misses, are cached per connection on the I/O queue and dropped on
screen reconfiguration or wake, so polling does not rescan the registry. Device signatures and
controller generations reject stale requests after reconnects. No brightness/volume/contrast values are automatically restored.
Only standard brightness (0x10), contrast (0x12), and speaker volume (0x62) are supported. An intact
Get VCP reply and nonzero range enable each control independently. Unsupported replies, invalid
@@ -626,7 +630,8 @@ that the display applied it. No software dimming or speculative compatibility wr
Blocking native work uses one bounded serial execution slot with a two-second caller deadline.
A timed-out kernel call retains the slot until it returns, so retries cannot accumulate stuck work.
Cancellation is checked between bus operations; late results cannot repopulate a closed or replaced
Each display read gets its own cancellation ticket, so one timed-out display does not skip the
others in the same refresh. Cancellation is checked between bus operations; late results cannot repopulate a closed or replaced
view. Protocol, timeout, cancellation and write-confirmation tests use simulated devices. To generate
a repeatable, read-only report on actual hardware (no Set VCP):
@@ -78,6 +78,34 @@ protocol DisplayDDCBackend: Sendable {
func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult]
func write(_ target: DisplayTarget, control: DisplayControl, percent: Double,
cancellation: DDCCancellation) async -> DDCResult
/// 显示链路可能已变化(屏幕配置变更、唤醒),丢弃已匹配的服务,下次读写重新匹配。
func resetConnections()
}
/// EDID 基本块中的厂商、产品与序列号,与 CGDisplay*Number 同源,用于确认 DDC 服务属于目标显示器。
struct EDIDSignature: Equatable, Sendable {
let vendor: UInt32
let product: UInt32
let serial: UInt32
init(vendor: UInt32, product: UInt32, serial: UInt32) {
self.vendor = vendor
self.product = product
self.serial = serial
}
init?(_ bytes: [UInt8]) {
guard bytes.count >= 128, bytes.prefix(8) == [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00] else { return nil }
vendor = UInt32(bytes[8]) << 8 | UInt32(bytes[9])
product = UInt32(bytes[10]) | UInt32(bytes[11]) << 8
serial = UInt32(bytes[12]) | UInt32(bytes[13]) << 8 | UInt32(bytes[14]) << 16 | UInt32(bytes[15]) << 24
}
/// 序列号为 0 表示 EDID 未提供,此时只比较厂商与产品。
func matches(_ other: EDIDSignature) -> Bool {
vendor == other.vendor && product == other.product
&& (serial == 0 || other.serial == 0 || serial == other.serial)
}
}
/// 写前重新确认范围,写后读取真实值;发送成功本身不等于显示器已应用。
@@ -61,6 +61,7 @@ final class DisplayController {
private func screenConfigurationChanged() {
invalidate()
backend.resetConnections()
readings = [:]
refreshCatalog()
deferCommunication(for: .seconds(1))
@@ -79,6 +80,7 @@ final class DisplayController {
isPaused = value
invalidate()
if !value {
backend.resetConnections()
refreshCatalog()
// 唤醒后让显示链路先稳定,期间不发送 DDC 报文。
deferCommunication(for: .seconds(3))
@@ -128,12 +130,13 @@ final class DisplayController {
func refresh() async {
guard visible, !isPaused, !isSettling, !Task.isCancelled, !isRefreshing, !isWriting, editingTokens.isEmpty else { return }
let epoch = generation
let ticket = DDCCancellation()
cancellation = ticket
isRefreshing = true
defer { if generation == epoch { isRefreshing = false; cancellation = nil } }
var updated: [UInt32: [DisplayControl: DDCResult]] = [:]
for display in catalog where !display.isBuiltIn {
// 超时会取消本台的令牌;每台独立,避免一台无响应导致后面的显示器跳过读取。
let ticket = DDCCancellation()
cancellation = ticket
let result = await withTaskCancellationHandler {
await backend.read(display.target, cancellation: ticket)
} onCancel: { ticket.cancel() }
@@ -146,6 +149,12 @@ final class DisplayController {
cancellation = nil
}
/// 手动重新检测:丢弃已缓存的服务匹配(含未找到),再读取一次。
func redetect() async {
backend.resetConnections()
await refresh()
}
func write(_ percent: Double, control: DisplayControl, display: DisplayInfo) async {
guard visible, !isPaused, !isSettling, !Task.isCancelled, !isRefreshing, !isWriting,
catalog.contains(where: { $0.target == display.target }),
@@ -10,7 +10,7 @@ struct DisplayDetails: View {
var body: some View {
SectionCard(title: tr("显示器"), trailing: {
RefreshButton(loading: model.displays.isRefreshing, help: tr("重新检测显示器控制")) {
Task { await model.displays.refresh() }
Task { await model.displays.redetect() }
}
}) {
if model.displays.catalog.isEmpty {
@@ -12,13 +12,20 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
private let queue = DispatchQueue(label: "work.12306.xstats.display-ddc", qos: .userInitiated)
private let lock = NSLock()
private var running = false
/// 只在 queue 内读写:注册表遍历成本高,按连接缓存匹配结果(含未找到),
/// 由 resetConnections() 在链路变化时清空,条目数不超过当前连接的外接屏。
private var services: [DisplayTarget: Service?] = [:]
func resetConnections() {
queue.async { [self] in services.removeAll() }
}
func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] {
let failed = Dictionary(uniqueKeysWithValues: DisplayControl.allCases.map { ($0, DDCResult.unavailable) })
return await execute(timeout: failed.mapValues { _ in .timedOut }, busy: failed.mapValues { _ in .busy },
cancellation: cancellation) {
guard !cancellation.isCancelled, let api = Functions.shared,
let service = Self.service(for: target, api: api) else { return failed }
let service = self.service(for: target, api: api) else { return failed }
var values: [DisplayControl: DDCResult] = [:]
for control in DisplayControl.allCases {
guard !cancellation.isCancelled else { values[control] = .cancelled; continue }
@@ -33,7 +40,7 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
guard percent.isFinite, (0...100).contains(percent) else { return .unavailable }
return await execute(timeout: .timedOut, busy: .busy, cancellation: cancellation) {
guard !cancellation.isCancelled, let api = Functions.shared,
let service = Self.service(for: target, api: api) else { return .unavailable }
let service = self.service(for: target, api: api) else { return .unavailable }
return DDCWriteTransaction.apply(percent: percent, cancellation: cancellation,
isCurrent: { Self.identity(for: target.id) == target.identity },
read: { Self.read(service, control: control, api: api, cancellation: cancellation) },
@@ -97,9 +104,19 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
let address: UInt32
}
/// 在 queue 内调用;命中缓存时仍校验连接身份,显示器下线或编号复用时不使用旧服务。
private func service(for target: DisplayTarget, api: Functions) -> Service? {
guard Self.identity(for: target.id) == target.identity else { return nil }
if let cached = services[target] { return cached }
let found = Self.matchService(for: target, api: api)
services[target] = found
return found
}
/// 通过 CoreDisplay 的注册表位置匹配 framebuffer;只接受一个明确对应的外接服务。
/// proxy 不在 framebuffer 子树下,只能按遍历顺序归属,因此再用服务自身的 EDID 排除其他显示器的 proxy。
/// 不用型号名、枚举序号或相近 EDID 猜测目标,以免同型号多屏发生错写。
private static func service(for target: DisplayTarget, api: Functions) -> Service? {
private static func matchService(for target: DisplayTarget, api: Functions) -> Service? {
guard identity(for: target.id) == target.identity, CGDisplayIsBuiltin(target.id) == 0,
let info = api.info(target.id)?.takeRetainedValue() as? [String: Any],
let location = info["IODisplayLocation"] as? String, !location.isEmpty else { return nil }
@@ -109,6 +126,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
var iterator: io_iterator_t = 0
guard IORegistryEntryCreateIterator(root, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iterator) == KERN_SUCCESS else { return nil }
defer { IOObjectRelease(iterator) }
let expected = EDIDSignature(vendor: CGDisplayVendorNumber(target.id), product: CGDisplayModelNumber(target.id),
serial: CGDisplaySerialNumber(target.id))
var matchingFramebuffer = false
var candidates: [Service] = []
for _ in 0..<20_000 {
@@ -124,7 +143,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
&& String(decoding: path.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) == location
} else if matchingFramebuffer && label == "DCPAVServiceProxy" {
let kind = IORegistryEntryCreateCFProperty(entry, "Location" as CFString, nil, 0)?.takeRetainedValue() as? String
guard kind == "External", let service = api.create(nil, entry)?.takeRetainedValue() else { continue }
guard kind == "External", let service = api.create(nil, entry)?.takeRetainedValue(),
edid(of: service, api: api)?.matches(expected) ?? true else { continue }
let provider = IORegistryEntrySearchCFProperty(entry, kIOServicePlane, "EPICProviderClass" as CFString,
nil, IOOptionBits(kIORegistryIterateParents | kIORegistryIterateRecursively)) as? String
// 部分 M1/M2 HDMI 桥使用另一条路由地址;协议校验和仍使用标准 DDC 地址。
@@ -135,6 +155,14 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
return candidates[0]
}
/// 读不到 EDID(符号缺失或链路不提供)时返回 nil,由调用方保留位置匹配结果。
private static func edid(of service: CFTypeRef, api: Functions) -> EDIDSignature? {
guard let copyEDID = api.copyEDID else { return nil }
var data: Unmanaged<CFData>?
guard copyEDID(service, &data) == KERN_SUCCESS, let bytes = data?.takeRetainedValue() as Data? else { return nil }
return EDIDSignature([UInt8](bytes))
}
private static func read(_ service: Service, control: DisplayControl, api: Functions,
cancellation: DDCCancellation) -> DDCResult {
for _ in 0..<2 {
@@ -162,10 +190,12 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
typealias Create = @convention(c) (CFAllocator?, io_service_t) -> Unmanaged<CFTypeRef>?
typealias Transfer = @convention(c) (CFTypeRef, UInt32, UInt32, UnsafeMutableRawPointer, UInt32) -> IOReturn
typealias Info = @convention(c) (UInt32) -> Unmanaged<CFDictionary>?
typealias CopyEDID = @convention(c) (CFTypeRef, UnsafeMutablePointer<Unmanaged<CFData>?>) -> IOReturn
let create: Create
let read: Transfer
let write: Transfer
let info: Info
let copyEDID: CopyEDID?
static let shared: Functions? = {
guard let io = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_LAZY),
let cd = dlopen("/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay", RTLD_LAZY),
@@ -174,7 +204,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
let write = dlsym(io, "IOAVServiceWriteI2C"),
let info = dlsym(cd, "CoreDisplay_DisplayCreateInfoDictionary") else { return nil }
return Functions(create: unsafeBitCast(create, to: Create.self), read: unsafeBitCast(read, to: Transfer.self),
write: unsafeBitCast(write, to: Transfer.self), info: unsafeBitCast(info, to: Info.self))
write: unsafeBitCast(write, to: Transfer.self), info: unsafeBitCast(info, to: Info.self),
copyEDID: dlsym(io, "IOAVServiceCopyEDID").map { unsafeBitCast($0, to: CopyEDID.self) })
}()
}
}
@@ -28,6 +28,27 @@ struct DDCProtocolTests {
#expect(DDCProtocol.parse(reply(current: 0, maximum: 0), control: .brightness) == .unavailable)
}
@Test func readsEDIDSignatureInCoreGraphicsByteOrder() throws {
// 本机 DELL U2422H 的真实值:CGDisplayVendorNumber 4268、ModelNumber 41400、SerialNumber 810567746
var edid = [UInt8](repeating: 0, count: 128)
edid.replaceSubrange(0..<16, with: [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00,
0x10, 0xac, 0xb8, 0xa1, 0x42, 0x48, 0x50, 0x30])
let signature = try #require(EDIDSignature(edid))
#expect(signature == EDIDSignature(vendor: 4268, product: 41400, serial: 810_567_746))
var badHeader = edid; badHeader[0] = 1
#expect(EDIDSignature(badHeader) == nil)
#expect(EDIDSignature(Array(edid.prefix(127))) == nil)
}
@Test func edidMatchRejectsOtherDisplaysAndToleratesMissingSerial() {
let target = EDIDSignature(vendor: 4268, product: 41400, serial: 810_567_746)
#expect(target.matches(target))
#expect(!target.matches(EDIDSignature(vendor: 4268, product: 41400, serial: 1)))
#expect(!target.matches(EDIDSignature(vendor: 4268, product: 41401, serial: 810_567_746)))
#expect(!target.matches(EDIDSignature(vendor: 1552, product: 41400, serial: 810_567_746)))
#expect(target.matches(EDIDSignature(vendor: 4268, product: 41400, serial: 0)))
}
@Test func preservesFullRangeAndRejectsInvalidUserValues() {
let value = DDCValue(current: 32768, maximum: 65535)
#expect(DDCProtocol.parse(reply(current: 32768, maximum: 65535), control: .brightness) == .value(value))
@@ -5,6 +5,13 @@ import Foundation
import Testing
@testable import XStatsUI
private final class ResetCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
var value: Int { lock.withLock { count } }
func increment() { lock.withLock { count += 1 } }
}
private actor TestDisplayBackend: DisplayDDCBackend {
var reads = 0
var writes = 0
@@ -13,9 +20,16 @@ private actor TestDisplayBackend: DisplayDDCBackend {
var gateReads = false
var gates: [CheckedContinuation<[DisplayControl: DDCResult], Never>] = []
var tickets: [DDCCancellation] = []
var timingOutTargets: Set<UInt32> = []
nonisolated let resets = ResetCounter()
func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] {
reads += 1
tickets.append(cancellation)
// 与 NativeDisplayDDC.execute 一致:超时时取消传入的令牌
if timingOutTargets.contains(target.id) {
cancellation.cancel()
return [.brightness: .timedOut]
}
if gateReads { return await withCheckedContinuation { gates.append($0) } }
return readResult
}
@@ -23,7 +37,9 @@ private actor TestDisplayBackend: DisplayDDCBackend {
writes += 1
return writeResult
}
nonisolated func resetConnections() { resets.increment() }
func block() { gateReads = true }
func timeOut(_ id: UInt32) { timingOutTargets.insert(id) }
func finish(_ value: UInt16) { gates.removeFirst().resume(returning: [.brightness: .value(DDCValue(current: value, maximum: 100))]) }
func setWriteResult(_ value: DDCResult) { writeResult = value }
func count() -> Int { reads }
@@ -48,6 +64,38 @@ struct DisplayControllerTests {
#expect(!controller.isRefreshing)
}
@Test func timeoutOnOneDisplayDoesNotSkipTheNextDisplay() async throws {
let backend = TestDisplayBackend()
await backend.timeOut(7)
let second = DisplayInfo(target: DisplayTarget(id: 8, identity: "fixture-second"), name: "Second Display",
isBuiltIn: false, isMain: false, summary: "2560×1440 · 60Hz")
let controller = DisplayController(backend: backend, catalog: [display, second])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 2)
try await settle(controller)
#expect(await backend.wasCancelled(0))
#expect(!(await backend.wasCancelled(1)))
#expect(controller.readings[7]?[.brightness] == .timedOut)
#expect(controller.readings[8]?[.brightness] == .value(DDCValue(current: 75, maximum: 100)))
}
@Test func wakeDropsMatchedConnectionsButVisibilityChangesKeepThem() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
controller.setVisible(false)
controller.setVisible(true)
#expect(backend.resets.value == 0)
controller.setPaused(true)
#expect(backend.resets.value == 0)
controller.setPaused(false)
#expect(backend.resets.value == 1)
await controller.redetect()
#expect(backend.resets.value == 2)
}
@Test func onlyVisibleControlsReadAndOnlyKnownSupportedControlsWrite() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])