Files
ysicing a7b894f386 fix(display): 上次超时的显示器排到最后读取
超时的内核调用返回前占着唯一执行槽,固定顺序读取时排在后面的显示器只会持续得到 busy。上一轮超时的显示器改为最后读取,仍每轮重试。
2026-10-04 21:52:14 +08:00

234 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
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
var readResult: [DisplayControl: DDCResult] = [.brightness: .value(DDCValue(current: 75, maximum: 100)), .volume: .unsupported]
var writeResult = DDCResult.value(DDCValue(current: 60, maximum: 100))
var gateReads = false
var gates: [CheckedContinuation<[DisplayControl: DDCResult], Never>] = []
var tickets: [DDCCancellation] = []
var timingOutTargets: Set<UInt32> = []
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) } }
return readResult
}
func write(_ target: DisplayTarget, control: DisplayControl, percent: Double, cancellation: DDCCancellation) async -> DDCResult {
writes += 1
return writeResult
}
nonisolated func resetConnections() { resets.increment() }
func block() { gateReads = true }
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 }
}
@MainActor
struct DisplayControllerTests {
private var display: DisplayInfo {
DisplayInfo(target: DisplayTarget(id: 7, identity: "fixture-connection"), name: "Fixture Display",
isBuiltIn: false, isMain: true, summary: "1920×1080 · 60Hz")
}
private func waitForReads(_ backend: TestDisplayBackend, _ count: Int) async throws {
let deadline = ContinuousClock.now + .seconds(30)
while await backend.count() < count, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(await backend.count() >= count)
}
private func settle(_ controller: DisplayController) async throws {
let deadline = ContinuousClock.now + .seconds(30)
while controller.isRefreshing, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#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 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])
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])
defer { controller.stop() }
await controller.refresh()
#expect(await backend.count() == 0)
controller.setVisible(true)
try await waitForReads(backend, 1)
try await settle(controller)
#expect(controller.readings[7]?[.volume] == .unsupported)
// 保护状态前的正常刷新可以增加总次数,之后应验证读取增量。
await controller.refresh()
let token = UUID()
controller.beginEditing(token)
try await settle(controller)
await controller.write(20, control: .volume, display: display)
await controller.write(.nan, control: .brightness, display: display)
#expect(await backend.writeCount() == 0)
await controller.write(60, control: .brightness, display: display)
#expect(await backend.writeCount() == 1)
#expect(controller.readings[7]?[.brightness] == .value(DDCValue(current: 60, maximum: 100)))
controller.setVisible(false)
let readsBeforeHiddenOperations = await backend.count()
await controller.refresh()
await controller.write(20, control: .brightness, display: display)
#expect(await backend.count() == readsBeforeHiddenOperations)
#expect(await backend.writeCount() == 1)
}
@Test func oldReadCannotPublishAfterCloseOrClearNewRequest() async throws {
let backend = TestDisplayBackend()
await backend.block()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 1)
controller.setVisible(false)
#expect(await backend.wasCancelled(0))
controller.setVisible(true)
try await waitForReads(backend, 2)
await backend.finish(10)
try await Task.sleep(for: .milliseconds(30))
#expect(controller.isRefreshing)
#expect(controller.readings.isEmpty)
await backend.finish(80)
try await settle(controller)
#expect(controller.readings[7]?[.brightness] == .value(DDCValue(current: 80, maximum: 100)))
}
@Test func pauseAndWakeSettlingCannotBeBypassedByManualRefreshOrReopen() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 1)
try await settle(controller)
await controller.refresh()
// 冻结后续周期读取并排空已有请求,再进入暂停状态。
controller.beginEditing(UUID())
try await settle(controller)
controller.setPaused(true)
let readsBeforePausedOperations = await backend.count()
await controller.refresh()
await controller.write(20, control: .brightness, display: display)
#expect(await backend.writeCount() == 0)
controller.setPaused(false)
#expect(controller.isSettling)
controller.setVisible(false)
controller.setVisible(true)
await controller.refresh()
#expect(controller.isSettling)
// 先结束可见需求,避免断言的异步读取遇到已到期的恢复任务。
controller.setVisible(false)
#expect(await backend.count() == readsBeforePausedOperations)
}
@Test func failedReadbackIsNotReportedAsSuccessfulAndEditingDefersPolling() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 1)
try await settle(controller)
let token = UUID()
await controller.refresh()
controller.beginEditing(token)
defer { controller.endEditing(token) }
try await settle(controller)
let readsBeforeEditingRefresh = await backend.count()
await controller.refresh()
#expect(await backend.count() == readsBeforeEditingRefresh)
await backend.setWriteResult(.unconfirmed(nil))
await controller.write(20, control: .brightness, display: display)
#expect(controller.readings[7]?[.brightness] == .unconfirmed(nil))
#expect(!controller.isWriting)
await controller.write(40, control: .brightness, display: display)
#expect(await backend.writeCount() == 1)
}
}