From a7b894f386057b8bc1854e3183f4792dfa92c6f2 Mon Sep 17 00:00:00 2001 From: ysicing Date: Sun, 4 Oct 2026 19:01:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(display):=20=E4=B8=8A=E6=AC=A1=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E6=98=BE=E7=A4=BA=E5=99=A8=E6=8E=92=E5=88=B0?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 超时的内核调用返回前占着唯一执行槽,固定顺序读取时排在后面的显示器只会持续得到 busy。上一轮超时的显示器改为最后读取,仍每轮重试。 --- ARCHITECTURE.md | 3 +- .../XStatsUI/Displays/DisplayController.swift | 6 ++- .../DisplayControllerTests.swift | 38 ++++++++++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e24fa7a..3d34c27 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -631,7 +631,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. 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 +others in the same refresh; a display that timed out last time is read last, so while its stuck call +still holds the execution slot, responsive displays are not starved by `.busy`. 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): diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift index 15cb993..d84b201 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift @@ -133,7 +133,11 @@ final class DisplayController { isRefreshing = true defer { if generation == epoch { isRefreshing = false; cancellation = nil } } var updated: [UInt32: [DisplayControl: DDCResult]] = [:] - for display in catalog where !display.isBuiltIn { + // 超时的内核调用返回前占着唯一的执行槽,紧随其后的读取只会得到 busy; + // 上一轮超时的显示器排到最后,响应正常的显示器不会被它持续挤掉。 + let external = catalog.filter { !$0.isBuiltIn } + let timedOut = { (display: DisplayInfo) in self.readings[display.id]?.values.contains(.timedOut) == true } + for display in external.filter({ !timedOut($0) }) + external.filter(timedOut) { // 超时会取消本台的令牌;每台独立,避免一台无响应导致后面的显示器跳过读取。 let ticket = DDCCancellation() cancellation = ticket diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift index a412254..49dcc63 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift @@ -21,13 +21,23 @@ private actor TestDisplayBackend: DisplayDDCBackend { var gates: [CheckedContinuation<[DisplayControl: DDCResult], Never>] = [] var tickets: [DDCCancellation] = [] var timingOutTargets: Set = [] + var readOrder: [UInt32] = [] + var slotHeld = false + var timeoutsHoldSlot = false nonisolated let resets = ResetCounter() func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] { reads += 1 tickets.append(cancellation) + readOrder.append(target.id) + // 与 NativeDisplayDDC.execute 一致:超时的调用返回前占着执行槽,紧随其后的请求得到 busy + if slotHeld { + slotHeld = false + return [.brightness: .busy] + } // 与 NativeDisplayDDC.execute 一致:超时时取消传入的令牌 if timingOutTargets.contains(target.id) { cancellation.cancel() + slotHeld = timeoutsHoldSlot return [.brightness: .timedOut] } if gateReads { return await withCheckedContinuation { gates.append($0) } } @@ -39,10 +49,15 @@ private actor TestDisplayBackend: DisplayDDCBackend { } nonisolated func resetConnections() { resets.increment() } func block() { gateReads = true } - func timeOut(_ id: UInt32) { timingOutTargets.insert(id) } + func timeOut(_ id: UInt32, holdingSlot: Bool = false) { + timingOutTargets.insert(id) + timeoutsHoldSlot = holdingSlot + } 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 } + func order() -> [UInt32] { readOrder } + func releaseSlot() { slotHeld = false } func writeCount() -> Int { writes } func wasCancelled(_ index: Int) -> Bool { tickets[index].isCancelled } } @@ -80,6 +95,27 @@ struct DisplayControllerTests { #expect(controller.readings[8]?[.brightness] == .value(DDCValue(current: 75, maximum: 100))) } + @Test func persistentlyTimedOutDisplayIsReadLastSoOthersGetTheSlot() async throws { + let backend = TestDisplayBackend() + await backend.timeOut(7, holdingSlot: true) + 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) + // 首轮按目录顺序:7 超时后占着执行槽,8 只得到 busy + #expect(await backend.order() == [7, 8]) + #expect(controller.readings[8]?[.brightness] == .busy) + await backend.releaseSlot() + await controller.refresh() + // 下一轮 7 排到最后,8 先拿到执行槽;7 仍会重试,不被永久放弃 + #expect(await backend.order() == [7, 8, 8, 7]) + #expect(controller.readings[8]?[.brightness] == .value(DDCValue(current: 75, maximum: 100))) + #expect(controller.readings[7]?[.brightness] == .timedOut) + } + @Test func wakeDropsMatchedConnectionsButVisibilityChangesKeepThem() async throws { let backend = TestDisplayBackend() let controller = DisplayController(backend: backend, catalog: [display])