fix(display): 缓存 DDC 服务匹配并隔离逐屏超时
- 按连接缓存 IORegistry 匹配结果,屏幕配置变化、唤醒和手动重新检测时清空,轮询不再每 5 秒全量遍历注册表 - DCPAVServiceProxy 按遍历顺序归属后,再用服务自身 EDID 与 CG 显示器的厂商、型号、序列号比对,排除其他显示器的服务 - 每台显示器读取使用独立取消令牌,一台超时不再导致后续显示器跳过
This commit is contained in:
1 parent
3c30a52077
commit
1bd75ba0db
7 files changed
+153
-11
No files matched your search
+8
-3
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user