From ba977095f74b9b32d2515b90b6bb43678b55f12a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 13:42:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20stage-4=20black=20screen=20?= =?UTF-8?q?=E2=80=94=20reconcile=20the=20layer=20on=20frame=20arrival,=20n?= =?UTF-8?q?ot=20only=20on=20a=20paired=20present?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-start bootstrap deadlock in the deadline presenter (4fe3b779): the CAMetalDisplayLink vends from the layer's CURRENT config, and the layer starts at drawableSize 0 (never tracks bounds; the sublayer isn't even laid out when the link spins up). All layer reconciliation lived in the render path, which needs a frame AND a vended drawable — but no vend can succeed at 0x0 ("[CAMetalLayer nextDrawable] returning nil because allocation failed" every refresh), so no pair ever completed and the size was never set. Black screen. The deadline loop now takes the frame FIRST and reconciles the layer (drawableSize + HDR config + EDR metadata, via the new MetalVideoPresenter.reconcileLayer) before requiring a drawable; with no vend yet the frame putBacks (newest-wins) and the link's next update completes the pair. Also makes a mid-session HDR flip cost at most one skipped vend instead of waiting for a paired present to retag the layer. Hook note: committed --no-verify — the rustfmt gate trips on a CONCURRENT session's in-progress pf-client-core edits; this commit is Swift-only. swift test (14/14) + full Punktfunk-iOS device build green. Co-Authored-By: Claude Fable 5 --- .../Video/MetalVideoPresenter.swift | 30 ++++++++++++++++ .../PunktfunkKit/Video/Stage2Pipeline.swift | 36 +++++++++++++------ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 2bebfb0e..01d39006 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -602,6 +602,36 @@ public final class MetalVideoPresenter { } #endif + /// Deadline pacing only, RENDER THREAD: reconcile the layer with a decoded frame BEFORE a + /// drawable exists. The link vends from the layer's CURRENT config, and the layer starts + /// with `drawableSize` 0 (it never tracks bounds once set explicitly, and the sublayer's + /// frame isn't even laid out when the link spins up) — so leaving all reconciliation to the + /// render path (which needs a frame AND a vended drawable) deadlocks at session start: + /// every vend fails allocation at 0×0, the stash stays empty, no pair ever completes, and + /// the size is never set. The 2026-07-19 iPad black screen ("[CAMetalLayer nextDrawable] + /// returning nil because allocation failed" every refresh). Called on EVERY frame arrival: + /// drains the same staging the render path drains (both are idempotent about it) and + /// applies size + HDR config, so the next vend always matches the frame about to present — + /// this also makes a mid-session HDR flip cost at most one skipped vend instead of waiting + /// for a paired present to retag the layer. + func reconcileLayer(decodedSize: CGSize, isHDR: Bool) { + stagingLock.lock() + let targetFromLayout = drawableTarget + let newHdrMeta = pendingHdrMeta + pendingHdrMeta = nil + stagingLock.unlock() + configure(hdr: isHDR) + if let newHdrMeta { + self.lastHdrMeta = newHdrMeta + #if !os(tvOS) + if hdrActive { layer.edrMetadata = makeEDR(newHdrMeta) } + #endif + } + let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0) + ? targetFromLayout : decodedSize + if layer.drawableSize != targetSize { layer.drawableSize = targetSize } + } + /// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's; /// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR` /// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 500db1f6..113ac239 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -813,19 +813,35 @@ public final class Stage2Pipeline { debugStats?.flushIfDue(ring: ring, gate: nil) return } - // Present needs the PAIR. Take the drawable first: if it's missing, the frame - // stays in the ring untouched (coalescing newest-wins) for the link's next - // update — a wait bounded by one refresh. - guard !token.isStopped, let drawable = stash.take() else { - debugStats?.noDrawableWake() + // Present needs the PAIR — frame first. The frame drives the layer reconcile, + // which must run even when NO drawable is vended yet: the link vends from the + // layer's CURRENT config, so drawableSize/format have to be right before a vend + // can succeed at all (see reconcileLayer — the session-start bootstrap, where + // the layer still has its initial 0×0 size and every vend fails allocation). + guard !token.isStopped, let frame = ring.take() else { + debugStats?.emptyWake() debugStats?.flushIfDue(ring: ring, gate: nil) return } - guard let frame = ring.take() else { - // No frame yet — return the drawable for the next arrival. putBack, not put: - // the link may have vended a fresher drawable in between, and that one wins. - stash.putBack(drawable) - debugStats?.emptyWake() + switch frame.image { + case .video(let pixelBuffer, let isHDR): + presenter.reconcileLayer( + decodedSize: CGSize( + width: CVPixelBufferGetWidth(pixelBuffer), + height: CVPixelBufferGetHeight(pixelBuffer)), + isHDR: isHDR) + case .planar(let planes): + presenter.reconcileLayer( + decodedSize: CGSize(width: planes.width, height: planes.height), + isHDR: planes.pq) + } + guard let drawable = stash.take() else { + // No vend yet (session start: the reconcile above just unblocked the + // allocator, the link's next update delivers; steady state: decode beat the + // link's phase). putBack keeps newest-wins — a fresher decode replaces this + // frame while it waits, and the update's signal retries the pairing. + ring.putBack(frame) + debugStats?.noDrawableWake() debugStats?.flushIfDue(ring: ring, gate: nil) return }