commit 5806ab0a34d10a0dcb47825b7a0b89be087a4803
parent 95213a8defde4ff69e4111a7ffc7c6d6fd00e7bd
Author: umhau <umhau@example.invalid>
Date: Wed, 21 Oct 2026 12:00:16 +0000
Recover headset links independently of UI and render native 90 Hz
Diffstat:
12 files changed, 726 insertions(+), 68 deletions(-)
diff --git a/Package.swift b/Package.swift
@@ -9,7 +9,7 @@ let package = Package(
name: "VRWorkstation",
linkerSettings: [
.linkedFramework("IOKit"), .linkedFramework("Metal"),
- .linkedFramework("MetalKit"), .linkedFramework("AppKit"),
+ .linkedFramework("QuartzCore"), .linkedFramework("AppKit"),
]
),
]
diff --git a/Sources/VRWorkstation/App.swift b/Sources/VRWorkstation/App.swift
@@ -1,11 +1,12 @@
// The app owns the headset: it wakes it over HID, captures its display (no desktop, no other windows),
-// renders the stereo scene fullscreen at 4320x2160 @ 60 Hz, and re-takes control the moment the display returns.
+// renders the stereo scene fullscreen at 4320x2160 @ 90 Hz, and re-takes control the moment the display returns.
//
// Health is watched PASSIVELY only (macOS display callbacks + the headset's own 0x05 status reports).
-// Never poll USB-C/DisplayPort port state (ioreg IOPortTransportStateDisplayPort): that polling made the
-// headset drop its link every ~70 s.
+// Avoid intrusive USB-C/DisplayPort polling. Drops have also occurred without polling;
+// use callbacks to observe the link and distinguish recovery from sustained stability.
import AppKit
-import MetalKit
+import Metal
+import QuartzCore
final class HeadsetWindow: NSWindow {
override var canBecomeKey: Bool { true }
@@ -26,20 +27,34 @@ func onlineHeadsetDisplay() -> CGDirectDisplayID? {
func log(_ s: String) { print(String(format: "%.3f ", Date().timeIntervalSince1970) + s) }
+private func isNativeHeadsetMode(_ mode: CGDisplayMode) -> Bool {
+ mode.pixelWidth == 4320 && mode.pixelHeight == 2160 && mode.width == 4320 && mode.height == 2160 && mode.refreshRate == 90
+}
+
private let displayChanged: CGDisplayReconfigurationCallBack = { id, flags, ctx in
guard !flags.contains(.beginConfigurationFlag), let ctx else { return }
+ guard flags.contains(.removeFlag) || flags.contains(.addFlag) || flags.contains(.setModeFlag) else { return }
+ let enteredAt = Date()
+ log("[display callback] id=\(id) flags=\(flags.rawValue) main=\(Thread.isMainThread)")
let session = Unmanaged<HeadsetSession>.fromOpaque(ctx).takeUnretainedValue()
- DispatchQueue.main.async {
+ if flags.contains(.removeFlag) {
+ session.recoveryDisplayRemoved(id)
+ }
+ if flags.contains(.addFlag), isHeadsetDisplay(id) { session.recoveryDisplayAdded(id) }
+ onMainRunLoop {
+ log(String(format: "[display callback] handled after %.1f ms", Date().timeIntervalSince(enteredAt) * 1000))
// Vendor metadata may already be gone when REMOVE is delivered.
if flags.contains(.removeFlag) { session.displayLost(id) }
if flags.contains(.addFlag), isHeadsetDisplay(id) { session.displayArrived(id) }
+ if flags.contains(.setModeFlag) { session.displayModeChanged(id) }
}
}
final class HeadsetSession: NSObject, NSApplicationDelegate {
- let hmd = HMD()
+ let hmd: HMD
let renderer: Renderer
- let view: MTKView
+ let view: MetalSurface
+ private let renderLoop: MetalRenderLoop
private var window: HeadsetWindow?
private(set) var display: CGDirectDisplayID?
private var lostAt: Date?
@@ -47,16 +62,22 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
private var healthTimer: Timer?
private var callbackRegistered = false
private var stopping = false
+ private var recoveryTimer: Timer?
+ private let recovery: LinkRecovery
+ private var lastHealthFrames = 0
+ private var lastHealthTime: Double = CACurrentMediaTime()
+ private var terminationTimer: Timer?
+ private var outputConfigured = false
- init(device: MTLDevice?) throws {
- view = MTKView(frame: .zero, device: device)
- view.colorPixelFormat = .bgra8Unorm
- view.depthStencilPixelFormat = .depth32Float
- view.preferredFramesPerSecond = 60
- renderer = try Renderer(view: view)
- view.delegate = renderer
- view.isPaused = true
+ init(device: MTLDevice?, recoveryHelper: URL) throws {
+ let headset = HMD()
+ hmd = headset
+ recovery = LinkRecovery(helper: recoveryHelper, wake: { try headset.screenOn() })
+ renderer = try Renderer(device: device)
+ view = MetalSurface(device: renderer.device)
+ renderLoop = MetalRenderLoop(layer: view.metalLayer, renderer: renderer)
super.init()
+ renderLoop.start()
}
func start() {
@@ -76,10 +97,22 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
startHID()
healthTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
guard let self else { return }
+ if let id = self.display { self.displayModeChanged(id) }
let age = self.hmd.lastIMUAt.map { String(format: "%.1fs", Date().timeIntervalSince($0)) } ?? "never"
- log("health display=\(self.display.map(String.init) ?? "absent") frames=\(self.renderer.frames) IMU=\(self.hmd.imuSamples) lastIMU=\(age) status=\(self.hmd.statusReports)")
+ let now = CACurrentMediaTime()
+ let metrics = self.renderer.frameMetrics()
+ let fps = Double(metrics.presented - self.lastHealthFrames) / (now - self.lastHealthTime)
+ self.lastHealthTime = now; self.lastHealthFrames = metrics.presented
+ log(String(format: "health display=%@ submitted=%d presented=%d skipped=%d fps=%.2f presentAge=%.2fs GPUms=%.2f GPUerrors=%d IMU=%d lastIMU=%@ status=%d",
+ self.display.map(String.init) ?? "absent", metrics.submitted,
+ metrics.presented, metrics.skipped, fps, now - metrics.lastPresentedTime,
+ metrics.gpuSeconds * 1000 / Double(max(1, metrics.completed)), metrics.errors,
+ self.hmd.imuSamples, age, self.hmd.statusReports))
+ }
+ if let id = onlineHeadsetDisplay() { displayArrived(id) } else {
+ log("waiting for the headset display...")
+ recover()
}
- if let id = onlineHeadsetDisplay() { displayArrived(id) } else { log("waiting for the headset display...") }
}
private func startHID() {
@@ -88,6 +121,7 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
try hmd.start(mode: .activate)
hidRetry?.invalidate(); hidRetry = nil
log("[hmd] wake commands accepted; waiting for IMU and video evidence")
+ if display == nil { recover() }
} catch { log("[hmd] \(error.localizedDescription)"); scheduleHIDRetry() }
}
@@ -98,16 +132,24 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) { stop() }
+ func stopAfter(_ seconds: Double) {
+ terminationTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { _ in NSApp.terminate(nil) }
+ }
+
func stop() {
+ guard !stopping else { return }
stopping = true
+ terminationTimer?.invalidate(); terminationTimer = nil
hidRetry?.invalidate(); hidRetry = nil
healthTimer?.invalidate(); healthTimer = nil
+ recoveryTimer?.invalidate(); recoveryTimer = nil
+ recovery.stop()
+ renderLoop.stop()
if callbackRegistered {
CGDisplayRemoveReconfigurationCallback(displayChanged, Unmanaged.passUnretained(self).toOpaque())
callbackRegistered = false
}
hmd.stop()
- view.isPaused = true
window?.orderOut(nil)
if let display { CGDisplayRelease(display) }
display = nil
@@ -115,15 +157,43 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
/// Takes exclusive control of the headset display and puts the renderer on it.
func displayArrived(_ id: CGDirectDisplayID) {
- guard !stopping, display != id else { return }
+ guard !stopping else { return }
+ if display == id { displayModeChanged(id); return }
+ if let previous = display { displayLost(previous) }
if let t = lostAt { log(String(format: "headset display back after %.0f ms", Date().timeIntervalSince(t) * 1000)) }
lostAt = nil
let r = CGDisplayCapture(id)
log("captured display \(id): \(r == .success ? "ok" : "error \(r.rawValue)")")
- guard r == .success else { return }
+ guard r == .success else { recover(); return }
display = id
- setNativeMode(id)
+ recovery.capturedDisplay(id)
+ recoveryTimer?.invalidate(); recoveryTimer = nil
+ guard setNativeMode(id) else {
+ CGDisplayRelease(id); display = nil
+ log("cannot render at required native mode")
+ recover()
+ return
+ }
+ configureOutput(id)
+ }
+ func displayModeChanged(_ id: CGDirectDisplayID) {
+ guard !stopping, display == id, let current = CGDisplayCopyDisplayMode(id) else { return }
+ let changed = !isNativeHeadsetMode(current)
+ if changed {
+ log("headset mode changed to \(current.pixelWidth)x\(current.pixelHeight)@\(current.refreshRate); restoring native 90 Hz")
+ renderLoop.setPaused(true)
+ outputConfigured = false
+ guard setNativeMode(id) else {
+ log("rendering paused: cannot restore native 90 Hz")
+ return
+ }
+ }
+ if changed || !outputConfigured { configureOutput(id) }
+ }
+
+ private func configureOutput(_ id: CGDirectDisplayID) {
+ guard !stopping, display == id else { return }
// A captured display is covered by the shielding window; ours must sit above it.
let bounds = CGDisplayBounds(id)
let mainH = CGDisplayBounds(CGMainDisplayID()).height
@@ -135,39 +205,62 @@ final class HeadsetSession: NSObject, NSApplicationDelegate {
win.setFrame(frame, display: true)
win.makeKeyAndOrderFront(nil)
window = win
- view.isPaused = false
- log("rendering on headset \(frame.size), drawable \(view.drawableSize)")
+ view.metalLayer.drawableSize = CGSize(width: 4320, height: 2160)
+ outputConfigured = true
+ renderLoop.setPaused(false)
+ log("rendering on headset \(frame.size), drawable \(view.metalLayer.drawableSize); dedicated Metal display loop at 90 Hz")
}
func displayLost(_ id: CGDirectDisplayID) {
guard id == display else { return }
- lostAt = Date()
+ if lostAt == nil { lostAt = Date() }
display = nil
- view.isPaused = true
+ outputConfigured = false
+ renderLoop.setPaused(true)
window?.orderOut(nil)
CGDisplayRelease(id)
log("headset display LOST")
recover()
}
- func panelOff() { log("headset reports panel off") }
+ func panelOff() { log("headset reports display not ready") }
+
+ // Callback entry must notify recovery before SkyLight can block the main thread.
+ func recoveryDisplayRemoved(_ id: CGDirectDisplayID) {
+ recovery.displayRemoved(id)
+ if Thread.isMainThread, display == id {
+ lostAt = Date()
+ renderLoop.setPaused(true)
+ }
+ }
+ func recoveryDisplayAdded(_ id: CGDirectDisplayID) { recovery.displayAdded(id) }
- /// DP recovery is not yet solved. Avoid replaying panel-off or controller resets on a live link.
func recover() {
- log("recovery: waiting for the display to return")
+ guard !stopping, display == nil, recoveryTimer == nil else { return }
+ if onlineHeadsetDisplay() != nil {
+ recoveryTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
+ guard let self else { return }
+ self.recoveryTimer = nil
+ guard !self.stopping, self.display == nil else { return }
+ if let id = onlineHeadsetDisplay() {
+ log("headset display online; retrying capture without a DP reset")
+ self.displayArrived(id)
+ } else { self.recovery.requestRecovery() }
+ }
+ } else { recovery.requestRecovery() }
}
- /// 4320x2160 pixels at 60 Hz, unscaled.
- private func setNativeMode(_ id: CGDirectDisplayID) {
+ /// 4320x2160 pixels at 90 Hz, unscaled.
+ private func setNativeMode(_ id: CGDirectDisplayID) -> Bool {
let opts = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary
let modes = (CGDisplayCopyAllDisplayModes(id, opts) as? [CGDisplayMode]) ?? []
- guard let m = modes.first(where: { $0.pixelWidth == 4320 && $0.pixelHeight == 2160 && $0.refreshRate == 60 && $0.width == 4320 })
- ?? modes.first(where: { $0.pixelWidth == 4320 && $0.pixelHeight == 2160 && $0.refreshRate == 60 }) else {
- log("no 4320x2160@60 mode"); return
+ guard let m = modes.first(where: isNativeHeadsetMode) else {
+ log("no unscaled 4320x2160@90 mode"); return false
}
- if CGDisplayCopyDisplayMode(id)?.ioDisplayModeID == m.ioDisplayModeID { return }
+ if CGDisplayCopyDisplayMode(id)?.ioDisplayModeID == m.ioDisplayModeID { return true }
let r = CGDisplaySetDisplayMode(id, m, nil)
- log("mode 4320x2160@60 (ui \(m.width)x\(m.height)): \(r == .success ? "ok" : "error \(r.rawValue)")")
+ log("mode 4320x2160@90 (ui \(m.width)x\(m.height)): \(r == .success ? "ok" : "error \(r.rawValue)")")
+ return r == .success
}
}
@@ -175,9 +268,24 @@ func runApp() {
let app = NSApplication.shared
app.setActivationPolicy(.regular)
let session: HeadsetSession
- do { session = try HeadsetSession(device: MTLCreateSystemDefaultDevice()) } catch { print("renderer failed:", error); exit(1) }
+ let helper = option("--recovery-helper").map { URL(fileURLWithPath: $0) }
+ ?? Bundle.main.executableURL!.deletingLastPathComponent().appendingPathComponent("hpmctl")
+ do { session = try HeadsetSession(device: MTLCreateSystemDefaultDevice(), recoveryHelper: helper) } catch { print("renderer failed:", error); exit(1) }
app.delegate = session
session.start()
+ if let value = option("--run-seconds") {
+ guard let seconds = Double(value), seconds > 0, seconds.isFinite else {
+ session.stop(); print("--run-seconds must be a positive number"); exit(2)
+ }
+ session.stopAfter(seconds)
+ }
app.activate(ignoringOtherApps: true)
- withExtendedLifetime(session) { app.run() }
+ signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN)
+ let signals = [SIGINT, SIGTERM].map { value -> DispatchSourceSignal in
+ let source = DispatchSource.makeSignalSource(signal: value, queue: .main)
+ source.setEventHandler { app.terminate(nil) }
+ source.resume()
+ return source
+ }
+ withExtendedLifetime((session, signals)) { app.run() }
}
diff --git a/Sources/VRWorkstation/HMD.swift b/Sources/VRWorkstation/HMD.swift
@@ -15,7 +15,7 @@ final class HMD {
enum Mode { case observe, activate }
var onIMU: ((IMUSample) -> Void)?
var onStatus: ((String) -> Void)?
- /// Panel power state from the headset's 0x05 status report (true = lit).
+ /// Display-ready flag inferred from 0x05. Physical panel illumination needs user confirmation.
var onPanel: ((Bool) -> Void)?
var onDisconnected: (() -> Void)?
private(set) var imuSamples = 0
@@ -23,8 +23,14 @@ final class HMD {
private(set) var lastIMUAt: Date?
private var managers: [IOHIDManager] = [] // must outlive the devices, or they get closed
+ private let ioLock = NSRecursiveLock()
private var control: IOHIDDevice?
private var sensors: IOHIDDevice?
+ var isConnected: Bool {
+ ioLock.lock(); defer { ioLock.unlock() }
+ return control != nil && sensors != nil
+ }
+ private var generation = 0
private let controlBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: 64)
private let sensorsBuf = UnsafeMutablePointer<UInt8>.allocate(capacity: 512)
@@ -36,6 +42,7 @@ final class HMD {
/// Observe registers callbacks only. Activate additionally wakes the panel and starts the IMU.
/// Callbacks fire on the main run loop.
func start(mode: Mode) throws {
+ ioLock.lock(); defer { ioLock.unlock() }
stop()
do {
control = try open(0x03f0, 0x0c6a, "QHMD control device")
@@ -51,7 +58,10 @@ final class HMD {
IOHIDDeviceRegisterRemovalCallback(d, { ctx, _, _ in
let hmd = Unmanaged<HMD>.fromOpaque(ctx!).takeUnretainedValue()
// Leave the IOKit callback before closing its manager.
- DispatchQueue.main.async { hmd.onDisconnected?() }
+ let generation = hmd.generation
+ onMainRunLoop {
+ if hmd.generation == generation { hmd.onDisconnected?() }
+ }
}, me)
IOHIDDeviceScheduleWithRunLoop(d, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
}
@@ -64,7 +74,7 @@ final class HMD {
}
_ = getFeature(0x09) // Preserve wake sequence; do not log device serials.
_ = getFeature(0x08); _ = getFeature(0x06)
- try setFeature([0x04, 0x01]) // screen on
+ try screenOn()
var imuOn = [UInt8](repeating: 0, count: 64); imuOn[0] = 0x02; imuOn[1] = 0x07
let result = IOHIDDeviceSetReport(sensors!, kIOHIDReportTypeOutput, 0x02, imuOn, imuOn.count)
guard result == kIOReturnSuccess else { throw hidError("IMU start", result) }
@@ -74,8 +84,16 @@ final class HMD {
}
}
+ func screenOn() throws {
+ ioLock.lock(); defer { ioLock.unlock() }
+ guard control != nil else { throw NotFound(name: "QHMD control device") }
+ try setFeature([0x04, 0x01])
+ }
+
/// Close host handles without sending panel-off, which can strand the DP link.
func stop() {
+ ioLock.lock(); defer { ioLock.unlock() }
+ generation += 1
for d in [control, sensors].compactMap({ $0 }) {
IOHIDDeviceRegisterRemovalCallback(d, nil, nil)
IOHIDDeviceRegisterInputReportCallback(d, d == control ? controlBuf : sensorsBuf, d == control ? 64 : 512, nil, nil)
@@ -117,7 +135,8 @@ final class HMD {
private func handleControl(_ p: UnsafeMutablePointer<UInt8>, _ n: Int) {
guard n >= 6 && p[0] == 0x05 else { return }
statusReports += 1
- onStatus?("panel \(p[1] == 1 ? "on" : "off"), measured refresh \(p[5] == 0 ? 0 : Int(p[5]) + 1) Hz")
+ let raw = (0..<n).map { String(format: "%02x", p[$0]) }.joined(separator: " ")
+ onStatus?("display ready=\(p[1] == 1); raw \(raw)")
onPanel?(p[1] == 1)
}
diff --git a/Sources/VRWorkstation/LinkRecovery.swift b/Sources/VRWorkstation/LinkRecovery.swift
@@ -0,0 +1,131 @@
+import Foundation
+import Darwin
+
+/// All deadlines and helper operations run independently of WindowServer's main-thread waits.
+final class LinkRecovery {
+ private let queue = DispatchQueue(label: "Headset link recovery", qos: .userInitiated)
+ private let helper: URL
+ private let wake: (() throws -> Void)?
+ private var process: Process?
+ private var timeout: DispatchSourceTimer?
+ private var pending: DispatchWorkItem?
+ private var attempts: [TimeInterval] = []
+ private var target: UInt32?
+ private var waiting = false
+ private var stopped = false
+ private var generation = 0
+
+ init(helper: URL, wake: (() throws -> Void)? = nil) {
+ self.helper = helper; self.wake = wake
+ }
+
+ func capturedDisplay(_ id: UInt32) {
+ queue.async { self.target = id; self.cancelWaiting() }
+ }
+
+ func displayRemoved(_ id: UInt32) {
+ queue.async { if self.target == id { self.beginWaiting() } }
+ }
+
+ func displayAdded(_ id: UInt32) {
+ queue.async { self.target = id; self.cancelWaiting() }
+ }
+
+ func requestRecovery() { queue.async { self.beginWaiting() } }
+
+ private func cancelWaiting() {
+ waiting = false; generation += 1
+ pending?.cancel(); pending = nil
+ }
+
+ private func beginWaiting() {
+ guard !stopped, !waiting else { return }
+ waiting = true
+ scheduleAttempt(after: 2)
+ }
+
+ private func scheduleAttempt(after seconds: Double) {
+ let expected = generation
+ let work = DispatchWorkItem { [weak self] in
+ guard let self, !self.stopped, self.waiting, self.generation == expected else { return }
+ do { try self.wake?() } catch {
+ log("recovery: USB wake failed: \(error)")
+ self.scheduleAttempt(after: 10)
+ return
+ }
+ log("recovery: display absent after grace period; resetting DP adapter")
+ self.startReset { [weak self] _ in
+ guard let self, self.waiting, !self.stopped, self.generation == expected else { return }
+ self.scheduleAttempt(after: 10)
+ }
+ }
+ pending = work
+ queue.asyncAfter(deadline: .now() + seconds, execute: work)
+ }
+
+ func reset(onCompletion: @escaping (Bool) -> Void) {
+ queue.async {
+ self.startReset { accepted in onMainRunLoop { onCompletion(accepted) } }
+ }
+ }
+
+ /// Called only on queue. ADD, rather than helper exit status, proves video recovery.
+ private func startReset(onCompletion: @escaping (Bool) -> Void) {
+ guard !stopped, process == nil else { onCompletion(false); return }
+ let now = ProcessInfo.processInfo.systemUptime
+ attempts.removeAll { now - $0 >= 60 }
+ guard attempts.count < 3 else {
+ log("recovery rate limit: at most 3 resets per minute")
+ onCompletion(false); return
+ }
+ guard FileManager.default.isExecutableFile(atPath: helper.path) else {
+ log("recovery helper missing: run tools/build.sh (expected \(helper.path))")
+ onCompletion(false); return
+ }
+ let command = Process()
+ command.executableURL = helper
+ command.arguments = ["1", "recover"]
+ command.standardOutput = FileHandle.standardOutput
+ command.standardError = FileHandle.standardError
+ var timedOut = false
+ command.terminationHandler = { [weak self] finished in
+ guard let self else { return }
+ self.queue.async {
+ guard self.process === finished else { return }
+ self.timeout?.cancel(); self.timeout = nil
+ self.process = nil
+ let accepted = !timedOut && finished.terminationStatus == 0
+ log("recovery helper \(accepted ? "accepted DRST; waiting for display ADD" : "failed (exit \(finished.terminationStatus))")")
+ onCompletion(accepted)
+ }
+ }
+ do {
+ try command.run()
+ process = command
+ attempts.append(now)
+ let timer = DispatchSource.makeTimerSource(queue: queue)
+ timer.schedule(deadline: .now() + 3)
+ timer.setEventHandler { [weak self] in
+ guard let self, self.process === command, command.isRunning else { return }
+ timedOut = true
+ log("recovery helper exceeded 3s; terminating")
+ kill(command.processIdentifier, SIGKILL)
+ }
+ timeout = timer
+ timer.resume()
+ } catch {
+ log("cannot launch recovery helper: \(error)")
+ onCompletion(false)
+ }
+ }
+
+ func stop() {
+ queue.sync {
+ stopped = true
+ cancelWaiting()
+ timeout?.cancel(); timeout = nil
+ if let process, process.isRunning { process.terminate() }
+ process = nil
+ }
+ }
+}
diff --git a/Sources/VRWorkstation/MainRunLoop.swift b/Sources/VRWorkstation/MainRunLoop.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+/// Defer out of driver callbacks and explicitly wake AppKit's run loop.
+/// Explicit waking avoids relying on dispatch-queue servicing during display-link rendering.
+func onMainRunLoop(_ action: @escaping () -> Void) {
+ CFRunLoopPerformBlock(CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue, action)
+ CFRunLoopWakeUp(CFRunLoopGetMain())
+}
diff --git a/Sources/VRWorkstation/MetalOutput.swift b/Sources/VRWorkstation/MetalOutput.swift
@@ -0,0 +1,107 @@
+import AppKit
+import Metal
+import QuartzCore
+
+/// AppKit owns only the surface. Rendering never calls NSView.draw or nextDrawable on main.
+final class MetalSurface: NSView {
+ let metalLayer = CAMetalLayer()
+
+ init(device: MTLDevice) {
+ super.init(frame: .zero)
+ metalLayer.device = device
+ metalLayer.pixelFormat = .bgra8Unorm
+ metalLayer.framebufferOnly = true
+ metalLayer.maximumDrawableCount = 3
+ metalLayer.drawableSize = CGSize(width: 4320, height: 2160)
+ metalLayer.contentsScale = 1 // native unscaled headset mode: one point per pixel
+ layer = metalLayer
+ wantsLayer = true
+ }
+
+ required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
+}
+
+/// One serial render worker for the lifetime of the session. The display link supplies
+/// a ready drawable and follows its layer's display, including reconnects and mode changes.
+final class MetalRenderLoop {
+ private let layer: CAMetalLayer
+ private let renderer: Renderer
+ private let lock = NSLock()
+ private var thread: Thread?
+ private var runLoop: CFRunLoop?
+ private var link: CAMetalDisplayLink? // touched only on the worker's run loop
+ private var paused = true
+ private var stopped = false
+ private let finished = DispatchSemaphore(value: 0)
+
+ init(layer: CAMetalLayer, renderer: Renderer) {
+ self.layer = layer; self.renderer = renderer
+ }
+
+ func start() {
+ let worker = Thread { [weak self] in self?.run() }
+ worker.name = "Headset Metal rendering"
+ worker.qualityOfService = .userInteractive
+ thread = worker
+ worker.start()
+ }
+
+ func setPaused(_ value: Bool) {
+ lock.lock()
+ paused = value
+ let loop = runLoop
+ lock.unlock()
+ guard let loop else { return }
+ CFRunLoopPerformBlock(loop, CFRunLoopMode.commonModes.rawValue) { [weak self] in self?.link?.isPaused = value }
+ CFRunLoopWakeUp(loop)
+ }
+
+ func stop() {
+ lock.lock()
+ stopped = true
+ let loop = runLoop
+ lock.unlock()
+ if let loop {
+ CFRunLoopStop(loop)
+ CFRunLoopWakeUp(loop)
+ }
+ if thread != nil && finished.wait(timeout: .now() + 0.5) == .timedOut {
+ log("render worker did not stop within 0.5s")
+ }
+ }
+
+ private func run() {
+ defer { finished.signal() }
+ autoreleasepool {
+ let loop = CFRunLoopGetCurrent()!
+ lock.lock()
+ if stopped { lock.unlock(); return }
+ runLoop = loop
+ let initiallyPaused = paused
+ lock.unlock()
+
+ let displayLink = CAMetalDisplayLink(metalLayer: layer)
+ displayLink.delegate = renderer
+ displayLink.preferredFrameRateRange = CAFrameRateRange(minimum: 90, maximum: 90, preferred: 90)
+ displayLink.preferredFrameLatency = 1
+ displayLink.isPaused = initiallyPaused
+ link = displayLink
+ displayLink.add(to: .current, forMode: .common)
+ let keepAlive = Port()
+ RunLoop.current.add(keepAlive, forMode: .default)
+ while true {
+ lock.lock()
+ let shouldStop = stopped
+ let interval = paused ? 0.25 : 0.01
+ lock.unlock()
+ if shouldStop { break }
+ // Drain drawable/update autoreleases on this dedicated thread each turn.
+ autoreleasepool { _ = CFRunLoopRunInMode(.defaultMode, interval, true) }
+ }
+ displayLink.invalidate() // exactly once, on its owning thread
+ link = nil
+ keepAlive.invalidate()
+ lock.lock(); runLoop = nil; lock.unlock()
+ }
+ }
+}
diff --git a/Sources/VRWorkstation/Renderer.swift b/Sources/VRWorkstation/Renderer.swift
@@ -1,13 +1,23 @@
// Side-by-side stereo renderer: one viewport per eye on the 4320x2160 headset panel.
import AppKit
import Metal
-import MetalKit
+import QuartzCore
import simd
struct Vertex { var pos: SIMD3<Float>; var normal: SIMD3<Float>; var color: SIMD3<Float> }
struct Uniforms { var mvp: simd_float4x4; var model: simd_float4x4 }
-final class Renderer: NSObject, MTKViewDelegate {
+struct FrameMetrics {
+ var presented = 0
+ var skipped = 0
+ var completed = 0
+ var errors = 0
+ var lastPresentedTime: Double = 0
+ var gpuSeconds: Double = 0
+ var submitted = 0
+}
+
+final class Renderer: NSObject, CAMetalDisplayLinkDelegate {
let device: MTLDevice
let queue: MTLCommandQueue
let pipeline: MTLRenderPipelineState
@@ -21,17 +31,17 @@ final class Renderer: NSObject, MTKViewDelegate {
var ipd: Float = 0.063
var fovY: Float = 100 * .pi / 180
- init(view: MTKView) throws {
- guard let dev = view.device ?? MTLCreateSystemDefaultDevice(), let q = dev.makeCommandQueue() else {
+ init(device: MTLDevice?) throws {
+ guard let dev = device, let q = dev.makeCommandQueue() else {
throw NSError(domain: "Renderer", code: 1, userInfo: [NSLocalizedDescriptionKey: "No Metal device"])
}
- device = dev; queue = q
+ self.device = dev; queue = q
let lib = try dev.makeLibrary(source: shaderSource, options: nil)
let pd = MTLRenderPipelineDescriptor()
pd.vertexFunction = lib.makeFunction(name: "vmain")
pd.fragmentFunction = lib.makeFunction(name: "fmain")
- pd.colorAttachments[0].pixelFormat = view.colorPixelFormat
- pd.depthAttachmentPixelFormat = view.depthStencilPixelFormat
+ pd.colorAttachments[0].pixelFormat = .bgra8Unorm
+ pd.depthAttachmentPixelFormat = .depth32Float
pipeline = try dev.makeRenderPipelineState(descriptor: pd)
let dd = MTLDepthStencilDescriptor()
dd.depthCompareFunction = .less
@@ -42,15 +52,62 @@ final class Renderer: NSObject, MTKViewDelegate {
sceneBuffer = dev.makeBuffer(bytes: scene.vertices, length: MemoryLayout<Vertex>.stride * scene.vertices.count)!
}
- func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
+ private let metricsLock = NSLock()
+ private var metrics = FrameMetrics()
+ private var displayDepth: MTLTexture? // serial render-worker ownership
+ func frameMetrics() -> FrameMetrics {
+ metricsLock.lock(); defer { metricsLock.unlock() }
+ return metrics
+ }
+ func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
+ autoreleasepool {
+ draw(update.drawable, presentationTime: update.targetPresentationTimestamp)
+ }
+ }
- var frames = 0
- func draw(in view: MTKView) {
- guard let rpd = view.currentRenderPassDescriptor, let drawable = view.currentDrawable,
- let cb = queue.makeCommandBuffer() else { return }
- frames += 1
- if frames == 1 || frames % 300 == 0 { print("frame \(frames) drawable \(view.drawableSize)") }
- encode(cb, rpd, size: view.drawableSize, time: Float(CACurrentMediaTime() - start))
+ private func draw(_ drawable: CAMetalDrawable, presentationTime: Double) {
+ let width = drawable.texture.width, height = drawable.texture.height
+ if displayDepth?.width != width || displayDepth?.height != height {
+ let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .depth32Float, width: width, height: height, mipmapped: false)
+ descriptor.usage = .renderTarget
+ descriptor.storageMode = .private
+ displayDepth = device.makeTexture(descriptor: descriptor)
+ }
+ guard let displayDepth, let cb = queue.makeCommandBuffer() else { return }
+ let rpd = MTLRenderPassDescriptor()
+ rpd.colorAttachments[0].texture = drawable.texture
+ rpd.colorAttachments[0].loadAction = .clear
+ rpd.colorAttachments[0].storeAction = .store
+ rpd.depthAttachment.texture = displayDepth
+ rpd.depthAttachment.loadAction = .clear
+ rpd.depthAttachment.storeAction = .dontCare
+ rpd.depthAttachment.clearDepth = 1
+ metricsLock.lock()
+ metrics.submitted += 1
+ let frame = metrics.submitted
+ metricsLock.unlock()
+ if frame == 1 { log("render worker main=\(Thread.isMainThread)") }
+ if frame == 1 || frame % 300 == 0 { print("frame \(frame) drawable \(width)x\(height)") }
+ encode(cb, rpd, size: CGSize(width: width, height: height), time: Float(presentationTime - start))
+ drawable.addPresentedHandler { [weak self] presented in
+ guard let self else { return }
+ self.metricsLock.lock(); defer { self.metricsLock.unlock() }
+ // Metal reports zero for a skipped drawable. Callback count alone overstates delivery.
+ if presented.presentedTime > 0 {
+ self.metrics.presented += 1
+ self.metrics.lastPresentedTime = max(self.metrics.lastPresentedTime, presented.presentedTime)
+ } else {
+ self.metrics.skipped += 1
+ }
+ }
+ cb.addCompletedHandler { [weak self] completed in
+ guard let self else { return }
+ self.metricsLock.lock(); defer { self.metricsLock.unlock() }
+ self.metrics.completed += 1
+ self.metrics.gpuSeconds += max(0, completed.gpuEndTime - completed.gpuStartTime)
+ if completed.status == .error { self.metrics.errors += 1 }
+ }
+ // CAMetalDisplayLink owns presentation timing; presentAtTime is invalid for its drawables.
cb.present(drawable)
cb.commit()
}
diff --git a/Sources/VRWorkstation/main.swift b/Sources/VRWorkstation/main.swift
@@ -1,5 +1,5 @@
import Foundation
-import MetalKit
+import Metal
import simd
setvbuf(stdout, nil, _IOLBF, 0)
// Usage: VRWorkstation | --observe <seconds> | --snapshot out.png [--snapshot-size WxH] [--snapshot-yaw deg]
@@ -29,10 +29,8 @@ if let i = CommandLine.arguments.firstIndex(of: "--snapshot") {
let dims = (option("--snapshot-size") ?? "4320x2160").split(separator: "x").compactMap { Int($0) }
let (w, h) = dims.count == 2 ? (dims[0], dims[1]) : (4320, 2160)
let yaw = Float(option("--snapshot-yaw") ?? "0") ?? 0
- let view = MTKView(frame: .zero, device: MTLCreateSystemDefaultDevice())
- view.colorPixelFormat = .bgra8Unorm; view.depthStencilPixelFormat = .depth32Float
do {
- let r = try Renderer(view: view)
+ let r = try Renderer(device: MTLCreateSystemDefaultDevice())
r.headOrientation = { simd_quatf(angle: yaw * .pi / 180, axis: [0, 1, 0]) }
try r.snapshot(width: w, height: h, time: 1.0, to: path)
print("wrote", path)
diff --git a/Tests/LinkRecoveryChecks.swift b/Tests/LinkRecoveryChecks.swift
@@ -0,0 +1,63 @@
+// Run without touching hardware:
+// swiftc Sources/VRWorkstation/{LinkRecovery,MainRunLoop}.swift Tests/LinkRecoveryChecks.swift -o /tmp/vr-recovery-checks-bin
+// /tmp/vr-recovery-checks-bin
+import Foundation
+
+func log(_ text: String) { print(text) }
+
+@main
+struct LinkRecoveryChecks {
+ static func attempt(_ recovery: LinkRecovery) -> Bool {
+ var result: Bool?
+ recovery.reset { result = $0 }
+ let deadline = Date().addingTimeInterval(6)
+ while result == nil && Date() < deadline {
+ RunLoop.main.run(until: Date().addingTimeInterval(0.02))
+ }
+ precondition(result != nil, "recovery did not complete within six seconds")
+ return result!
+ }
+
+ static func main() throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent("vr-helper-check-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ func helper(_ name: String, _ body: String) throws -> URL {
+ let url = directory.appendingPathComponent(name)
+ try body.write(to: url, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
+ return url
+ }
+ let ok = try helper("ok", "#!/bin/sh\nexit 0\n")
+ let limited = LinkRecovery(helper: ok)
+ precondition((0..<4).map { _ in attempt(limited) } == [true, true, true, false],
+ "helper must run at most three times per minute")
+
+ let stuck = LinkRecovery(helper: try helper("stuck", "#!/bin/sh\nexec /bin/sleep 10\n"))
+ let start = Date()
+ precondition(!attempt(stuck), "a timed-out helper must fail")
+ let seconds = Date().timeIntervalSince(start)
+ precondition(seconds >= 2.9 && seconds < 5, "helper must be killed after about three seconds")
+
+ let missing = LinkRecovery(helper: directory.appendingPathComponent("missing"))
+ precondition(!attempt(missing), "a missing helper must fail")
+ // A headset ADD must cancel startup recovery even before capture binds an ID.
+ let marker = directory.appendingPathComponent("ran")
+ let recording = try helper("recording", "#!/bin/sh\n/usr/bin/touch \(marker.path)\n")
+ let startup = LinkRecovery(helper: recording)
+ startup.requestRecovery()
+ startup.displayAdded(42)
+ Thread.sleep(forTimeInterval: 2.3) // deliberately do not pump main
+ precondition(!FileManager.default.fileExists(atPath: marker.path), "startup ADD must cancel reset")
+ startup.capturedDisplay(42)
+ startup.displayRemoved(42)
+ startup.displayAdded(43)
+ Thread.sleep(forTimeInterval: 2.3)
+ precondition(!FileManager.default.fileExists(atPath: marker.path), "changed display ID must cancel reset")
+ startup.displayRemoved(43)
+ Thread.sleep(forTimeInterval: 2.5)
+ precondition(FileManager.default.fileExists(atPath: marker.path), "recovery deadline must fire while main is blocked")
+ startup.stop()
+ print("PASS: three resets/minute; stuck helper stopped in \(seconds)s; missing helper rejected")
+ }
+}
diff --git a/tools/build.sh b/tools/build.sh
@@ -0,0 +1,10 @@
+#!/bin/zsh
+# Build the app and its narrow, entitled recovery helper together.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+swift build
+vr_bin_dir=$(swift build --show-bin-path)
+clang -Wall -Wextra -O2 -framework IOKit -framework CoreFoundation tools/hpmctl.c -o "$vr_bin_dir/hpmctl"
+codesign --force --sign - --entitlements tools/hpmctl.entitlements "$vr_bin_dir/hpmctl"
+print "App: $vr_bin_dir/VRWorkstation"
+print "Recovery helper: $vr_bin_dir/hpmctl"
diff --git a/tools/hpmctl.c b/tools/hpmctl.c
@@ -32,7 +32,7 @@ typedef struct HPMInterface {
0xB7, 0xBE, 0xE0, 0x5C, 0xD8, 0x83, 0xC7, 0xB1)
/// Finds the AppleHPM bus service whose child AppleHPMDevice has the given RID, and opens the plug-in on it.
-static HPMInterface **openHPM(int rid) {
+static HPMInterface **openHPM(int rid, int verifyPort) {
io_iterator_t it;
if (IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleHPMDevice"), &it) != KERN_SUCCESS) return NULL;
io_service_t dev, found = 0;
@@ -40,7 +40,24 @@ static HPMInterface **openHPM(int rid) {
CFNumberRef n = IORegistryEntryCreateCFProperty(dev, CFSTR("RID"), NULL, 0);
int v = -1;
if (n) { CFNumberGetValue(n, kCFNumberIntType, &v); CFRelease(n); }
- if (v == rid) IORegistryEntryGetParentEntry(dev, kIOServicePlane, &found); // AppleHPMARMI2C
+ if (v == rid) {
+ int correctPort = !verifyPort;
+ io_iterator_t children;
+ if (verifyPort && IORegistryEntryGetChildIterator(dev, kIOServicePlane, &children) == KERN_SUCCESS) {
+ io_registry_entry_t child;
+ while ((child = IOIteratorNext(children))) {
+ io_string_t path;
+ if (IORegistryEntryGetPath(child, kIOServicePlane, path) == KERN_SUCCESS) {
+ const char *last = strrchr(path, '/');
+ if (last && strcmp(last, "/Port-USB-C@2") == 0) correctPort = 1;
+ }
+ IOObjectRelease(child);
+ }
+ IOObjectRelease(children);
+ }
+ if (correctPort) IORegistryEntryGetParentEntry(dev, kIOServicePlane, &found);
+ else fprintf(stderr, "RID %d is not verified as Port-USB-C@2; refusing command\n", rid);
+ }
IOObjectRelease(dev);
}
IOObjectRelease(it);
@@ -59,17 +76,33 @@ static HPMInterface **openHPM(int rid) {
}
int main(int argc, char **argv) {
- if (argc < 4) { fprintf(stderr, "usage: hpmctl <rid> read <reg> | hpmctl <rid> cmd <4cc>\n"); return 2; }
- int rid = atoi(argv[1]);
+ if (argc < 3) { fprintf(stderr, "usage: hpmctl <rid> read <reg> | hpmctl <rid> cmd <4cc> | hpmctl 1 recover\n"); return 2; }
+ char *end;
+ long ridValue = strtol(argv[1], &end, 10);
+ if (!*argv[1] || *end || ridValue < 0 || ridValue > 1) { fprintf(stderr, "RID must be 0 or 1\n"); return 2; }
+ int rid = (int)ridValue;
+ if (strcmp(argv[2], "recover") == 0) {
+ if (rid != 1 || argc != 3) { fprintf(stderr, "recovery is restricted to RID 1 (DP adapter)\n"); return 2; }
+ HPMInterface **hpm = openHPM(rid, 1);
+ if (!hpm) return 1;
+ IOReturn r = (*hpm)->iecsCommand(hpm, 0, 0x44525354, 0); // BE numeric 'DRST'
+ uint8_t data[64] = {0};
+ uint64_t got = 0;
+ if (r == kIOReturnSuccess) r = (*hpm)->iecsRead(hpm, 0, 0x09, data, sizeof data, 0, &got);
+ printf("rid 1 recover DRST: 0x%08x, task result: 0x%02x, bytes: %llu\n", r, data[0], got);
+ (*hpm)->Release(hpm);
+ return r == kIOReturnSuccess && got >= 1 && data[0] == 0 ? 0 : 1;
+ }
+ if (argc != 4) { fprintf(stderr, "read/cmd require one argument\n"); return 2; }
if (strcmp(argv[2], "cmd") == 0) {
- if (rid == 0) { fprintf(stderr, "refusing to send commands to RID 0 (hub + power)\n"); return 2; }
+ if (rid != 1) { fprintf(stderr, "refusing to send commands to RID 0 (hub + power)\n"); return 2; }
if (strlen(argv[3]) != 4) { fprintf(stderr, "4cc must be 4 characters\n"); return 2; }
// iecsCommand takes a C-style, big-endian numeric 4CC ('DRST' -> 0x44525354).
// AppleHPM byte-swaps it before writing ASCII bytes to CMD1. Passing a little-endian
// number sends a reversed command ("TSRD") and the controller responds "!CMD".
uint32_t cc = (uint32_t)(unsigned char)argv[3][0] << 24 | (uint32_t)(unsigned char)argv[3][1] << 16
| (uint32_t)(unsigned char)argv[3][2] << 8 | (uint32_t)(unsigned char)argv[3][3];
- HPMInterface **hpm = openHPM(rid);
+ HPMInterface **hpm = openHPM(rid, 1);
if (!hpm) return 1;
IOReturn r = (*hpm)->iecsCommand(hpm, 0, cc, 0);
printf("rid %d cmd '%s' (0x%08x): 0x%08x\n", rid, argv[3], cc, r);
@@ -78,7 +111,7 @@ int main(int argc, char **argv) {
}
if (strcmp(argv[2], "read") != 0) { fprintf(stderr, "unknown op %s\n", argv[2]); return 2; }
uint8_t reg = (uint8_t)strtoul(argv[3], NULL, 0);
- HPMInterface **hpm = openHPM(rid);
+ HPMInterface **hpm = openHPM(rid, 0);
if (!hpm) return 1;
uint8_t buf[64] = {0};
uint64_t got = 0;
diff --git a/tools/recovery_trials.py b/tools/recovery_trials.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Bounded hardware integration test: panel off/on, then DP recovery via DRST.
+
+Requires sole ownership of the headset and the built displaywatch, hidcmd and
+signed hpmctl tools. Observes CoreGraphics callbacks; never polls port registers.
+Leaves the headset on after each successful trial. Stops on the first failure.
+"""
+import argparse
+import json
+import queue
+import re
+import subprocess
+import threading
+import time
+from pathlib import Path
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--trials", type=int, default=3)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--app-recovery", action="store_true", help="wait for the running app to recover; do not issue DRST from this test")
+ parser.add_argument("--leave-off", action="store_true", help="let the app send screen-on as part of recovery")
+ parser.add_argument("--settle-seconds", type=float, default=10, help="observe this long after ADD, including any short self-healing drops")
+ args = parser.parse_args()
+ if not 1 <= args.trials <= 10:
+ parser.error("--trials must be between 1 and 10")
+ if not 0 <= args.settle_seconds <= 60:
+ parser.error("--settle-seconds must be between 0 and 60")
+ if args.leave_off and not args.app_recovery:
+ parser.error("--leave-off requires --app-recovery")
+ tools = Path(__file__).resolve().parent
+ events = queue.Queue()
+ report = {"started": time.time(), "app_recovery": args.app_recovery, "leave_off": args.leave_off,
+ "commands": [], "display_events": [], "trials": []}
+ watcher = subprocess.Popen([str(tools / "displaywatch")], stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, text=True, bufsize=1)
+
+ def read_events():
+ for line in watcher.stdout:
+ events.put({"received": time.time(), "line": line.strip()})
+
+ threading.Thread(target=read_events, daemon=True).start()
+
+ def command(name, *arguments):
+ record = {"started": time.time(), "argv": [name, *arguments]}
+ argv = [str(tools / name), *arguments]
+ if name == "hpmctl":
+ argv = ["sudo", "-n", *argv]
+ result = subprocess.run(argv, capture_output=True, text=True, timeout=20)
+ record.update(ended=time.time(), code=result.returncode,
+ stdout=result.stdout.strip(), stderr=result.stderr.strip())
+ report["commands"].append(record)
+ if result.returncode or (name == "hidcmd" and result.stdout.split()[-1:] != ["0"]):
+ raise RuntimeError(f"command failed: {record}")
+ return record["started"]
+
+ def await_display(online, timeout):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if watcher.poll() is not None:
+ raise RuntimeError("passive display watcher exited")
+ try:
+ event = events.get(timeout=min(0.2, max(0.001, deadline - time.monotonic())))
+ except queue.Empty:
+ continue
+ report["display_events"].append(event)
+ if bool(re.search(r"\b\d+:8718\s", event["line"])) == online:
+ return event
+ return None
+
+ try:
+ if not await_display(True, 3):
+ raise RuntimeError("headset display must be online before the first trial")
+ for index in range(args.trials):
+ trial = {"index": index + 1}
+ report["trials"].append(trial)
+ command("hidcmd", "off")
+ removed = await_display(False, 5)
+ if not removed:
+ raise RuntimeError("panel-off did not remove the display")
+ if not args.leave_off:
+ command("hidcmd", "on")
+ restored = await_display(True, 2)
+ if restored:
+ trial["outcome"] = "returned_after_screen_on_without_DRST"
+ else:
+ reset_at = time.time() if args.app_recovery else command("hpmctl", "1", "cmd", "DRST")
+ event = await_display(True, 15)
+ if not event:
+ raise RuntimeError("DRST did not restore the display within 15 seconds")
+ restored = event
+ trial.update(outcome="recovered_by_app" if args.app_recovery else "recovered_after_DRST",
+ seconds=event["received"] - reset_at)
+ trial["outage_seconds"] = restored["received"] - removed["received"]
+ settle_until = time.monotonic() + args.settle_seconds
+ trial["post_recovery_drops"] = []
+ while time.monotonic() < settle_until:
+ removed = await_display(False, settle_until - time.monotonic())
+ if removed:
+ restored = await_display(True, 15)
+ if not restored:
+ raise RuntimeError("post-recovery drop did not heal within 15 seconds")
+ trial["post_recovery_drops"].append(restored["received"] - removed["received"])
+ print(json.dumps(trial), flush=True)
+ report["result"] = "passed"
+ except Exception as error:
+ report.update(result="failed", error=str(error))
+ print(str(error), flush=True)
+ finally:
+ watcher.terminate()
+ try:
+ watcher.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ watcher.kill()
+ watcher.wait()
+ report["ended"] = time.time()
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(json.dumps(report, indent=2) + "\n")
+ return 0 if report["result"] == "passed" else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())