From 9aebc3f251810e28cbf305d1371891d80bb7c858 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 11:17:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20default=20macOS=20PyroWave=20sess?= =?UTF-8?q?ions=20to=20glass-gated=20present=20pacing=20=E2=80=94=20DCP=20?= =?UTF-8?q?swapID=20kernel-panic=20mitigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windowed PyroWave sessions were kernel-panicking Macs ("mismatched swapID's" @UnifiedPipeline.cpp, WindowServer → AppleMobileDisp*-DCP). The panic is an Apple DCP bug (SketchUp/Unity/240Hz-monitor reports hit the same signature), but our stage-2 arrival pacing feeds the trigger pattern: displaySyncEnabled=false out-of-band presents arriving faster than the compositor latches them in a composited (windowed) session. PyroWave makes that pattern routine — near-instant Metal wavelet decode turns network clumps into same-millisecond present bursts, and it is the codec that sustains stream rates above panel refresh. Mitigation: PyroWave sessions on macOS now default to the stage-3 PresentGate (one presented-but-undisplayed swap in flight, serialized on the on-glass callback, 100 ms stale backstop) — the racing pattern cannot occur. Explicit stage2/stage3 picks (setting or PUNKTFUNK_PRESENTER) still win, so the arrival-pacing A/B stays honest. VideoToolbox codecs keep arrival pacing (decode latency spaces their presents; no panic reports there). PresenterChoice gains an `explicit` resolver (nil = no user selection) so the codec-conditional default only applies when the user hasn't picked a stage; pacing selection is a testable helper carrying the rationale. Co-Authored-By: Claude Fable 5 --- .../PunktfunkKit/Video/SessionPresenter.swift | 43 +++++++++++++++++-- .../PunktfunkKit/Video/Stage2Pipeline.swift | 5 +++ .../PresentPacingTests.swift | 39 +++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index 93a1cadc..b3f43a37 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -42,12 +42,20 @@ enum PresenterChoice: Equatable { /// leftover DEBUG "stage1" value silently maps to the default rather than reviving the /// freeze-prone fallback. static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice { + explicit(setting: setting, env: env, allowStage1: allowStage1) ?? platformDefault + } + + /// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values, + /// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default + /// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage — an + /// explicit "stage2" must stay a faithful A/B of arrival pacing. + static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? { let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting switch raw { - case "stage1": return allowStage1 ? .stage1 : platformDefault + case "stage1": return allowStage1 ? .stage1 : nil case "stage2": return .stage2 case "stage3": return .stage3 - default: return platformDefault + default: return nil } } @@ -66,6 +74,31 @@ enum PresenterChoice: Equatable { } final class SessionPresenter { + /// Present pacing for this session. Stage-3 always means glass gating; under the stage-2 + /// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a + /// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole + /// machine dies) when WindowServer's swap submissions race, and the reliable trigger is + /// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see + /// MetalVideoPresenter's init) arriving faster than the compositor latches them in a + /// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet + /// decode is near-instant Metal compute, so a network clump of frames presents within the + /// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's + /// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on + /// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright; + /// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit + /// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest. + /// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years + /// of stage-2 defaults there predate any panic report. + static func pacing( + for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec + ) -> PresentPacing { + if choice == .stage3 { return .glass } + #if os(macOS) + if explicit == nil, codec == .pyrowave { return .glass } + #endif + return .arrival + } + private var pump: StreamPump? private var stage2: Stage2Pipeline? private var stage2Link: CADisplayLink? @@ -112,15 +145,17 @@ final class SessionPresenter { #else let allowStage1 = false #endif - let choice = PresenterChoice.resolve( + let explicit = PresenterChoice.explicit( setting: UserDefaults.standard.string(forKey: DefaultsKey.presenter), env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"], allowStage1: allowStage1) + let choice = explicit ?? PresenterChoice.platformDefault if choice != .stage1, let pipeline = Stage2Pipeline( endToEndMeter: endToEndMeter, decodeMeter: decodeMeter, displayMeter: displayMeter, - pacing: choice == .stage3 ? .glass : .arrival) { + pacing: Self.pacing( + for: choice, explicit: explicit, codec: connection.videoCodec)) { let metal = pipeline.layer // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 58180106..7c32ec3e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -122,6 +122,11 @@ private final class VsyncClock: @unchecked Sendable { /// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before /// present instead of queueing them behind the display — the hidden queue latency becomes /// explicit, correct frame drops. +/// +/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst +/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP +/// "mismatched swapID's" KERNEL PANIC, and the one-in-flight gate removes that pattern — see +/// `SessionPresenter.pacing` for the full rationale. public enum PresentPacing: Sendable { case arrival case glass diff --git a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift index 99fd4f7a..a574424c 100644 --- a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift @@ -79,5 +79,44 @@ final class PresentPacingTests: XCTestCase { XCTAssertEqual( PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2) } + + /// `explicit` is nil exactly when `resolve` would fall back to the platform default — the + /// distinction the codec-conditional pacing default rides on. + func testPresenterChoiceExplicitIsNilWithoutASelection() { + XCTAssertNil(PresenterChoice.explicit(setting: nil, env: nil, allowStage1: true)) + XCTAssertNil(PresenterChoice.explicit(setting: "garbage", env: nil, allowStage1: true)) + XCTAssertNil(PresenterChoice.explicit(setting: "stage1", env: nil, allowStage1: false)) + XCTAssertEqual( + PresenterChoice.explicit(setting: "stage2", env: nil, allowStage1: true), .stage2) + XCTAssertEqual( + PresenterChoice.explicit(setting: nil, env: "stage3", allowStage1: true), .stage3) + } + + // MARK: - Session pacing (the macOS PyroWave swapID-panic mitigation) + + /// macOS PyroWave sessions under the DEFAULT stage-2 choice must get glass pacing (the + /// one-in-flight gate is the "mismatched swapID's" kernel-panic mitigation); an EXPLICIT + /// stage-2 pick must stay a faithful arrival-pacing A/B. Elsewhere the default is unchanged. + func testPacingDefaultsPyroWaveToGlassOnMacOS() { + #if os(macOS) + XCTAssertEqual( + SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .glass, + "defaulted macOS PyroWave must serialize presents (swapID-panic mitigation)") + XCTAssertEqual( + SessionPresenter.pacing(for: .stage2, explicit: .stage2, codec: .pyrowave), .arrival, + "an explicit stage-2 pick must keep arrival pacing (honest A/B)") + #else + XCTAssertEqual( + SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .arrival) + #endif + // Non-PyroWave defaults keep arrival pacing under stage-2 everywhere. + XCTAssertEqual( + SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .hevc), .arrival) + // Stage-3 means glass regardless of codec or how it was chosen. + XCTAssertEqual( + SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass) + XCTAssertEqual( + SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass) + } } #endif