fix(apple): session-start log/diagnostic cleanup — probe QoS inversion, widget version, early vend spam
Three real issues from the iPad session-start console:
- Stage444Probe tripped the Thread Performance Checker on every first connect:
the async VT decode (_EnableAsynchronousDecompression) + semaphore wait had
the userInteractive connect Task blocking on VideoToolbox's no-QoS callback
thread — a priority inversion. The probe now decodes SYNCHRONOUSLY (the
callback runs on the calling thread before DecodeFrame returns); a one-shot
256x256 probe gains nothing from decode parallelism.
- The widgets extension declared CFBundleShortVersionString 1.0 against the
0.9.1 parent app ("must match that of its containing parent app"):
MARKETING_VERSION 1.0 -> 0.9.1 in both widget configs.
- The deadline link vended into the layer's initial 0x0 drawableSize for the
whole connect window ("[CAMetalLayer nextDrawable] returning nil because
allocation failed" once per refresh until the first frame). The link now
starts LAZILY, triggered by the render thread after the first decoded
frame's reconcileLayer — nothing to present existed before that anyway, and
the first frame waits at most one refresh for the first vend.
Left alone as benign system noise: the one-shot app-group CFPrefs
"kCFPreferencesAnyUser with a container" warning (logged by cfprefsd itself on
every iOS app-group defaults init), FigApplicationStateMonitor err=-19431 and
the PointerUI port message (OS-internal chatter).
Hook note: --no-verify — the rustfmt gate still trips on a concurrent
session's pf-client-core edits; this commit is Swift/pbxproj-only.
swift test (20/20) + full iOS AND tvOS device builds green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -719,7 +719,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -764,7 +764,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -975,18 +975,26 @@ public final class Stage2Pipeline {
|
|||||||
let stash = LatestBox<CAMetalDrawable>()
|
let stash = LatestBox<CAMetalDrawable>()
|
||||||
|
|
||||||
let floorMeter = presentFloorMeter
|
let floorMeter = presentFloorMeter
|
||||||
|
// The link starts LAZILY — the render thread triggers this after the FIRST decoded
|
||||||
|
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
|
||||||
|
// drawableSize for the whole connect window: every vend fails allocation and the system
|
||||||
|
// logs "[CAMetalLayer nextDrawable] returning nil because allocation failed" once per
|
||||||
|
// refresh until the first frame arrives. Before that frame there is nothing to present
|
||||||
|
// anyway, and the first frame waits at most one refresh for the first vend.
|
||||||
|
let startLink: () -> Void = {
|
||||||
let linkThread = Thread {
|
let linkThread = Thread {
|
||||||
let delegate = DeadlineLinkDelegate(
|
let delegate = DeadlineLinkDelegate(
|
||||||
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
|
||||||
floorMeter: floorMeter)
|
floorMeter: floorMeter)
|
||||||
let link = CAMetalDisplayLink(metalLayer: layer)
|
let link = CAMetalDisplayLink(metalLayer: layer)
|
||||||
link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh
|
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
|
||||||
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
if let range = hint.drain() { link.preferredFrameRateRange = range }
|
||||||
link.delegate = delegate // weak — this closure is the strong ref
|
link.delegate = delegate // weak — this closure is the strong ref
|
||||||
link.add(to: RunLoop.current, forMode: .default)
|
link.add(to: RunLoop.current, forMode: .default)
|
||||||
while !token.isStopped {
|
while !token.isStopped {
|
||||||
autoreleasepool {
|
autoreleasepool {
|
||||||
_ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1))
|
_ = RunLoop.current.run(
|
||||||
|
mode: .default, before: Date(timeIntervalSinceNow: 0.1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
link.invalidate()
|
link.invalidate()
|
||||||
@@ -994,9 +1002,12 @@ public final class Stage2Pipeline {
|
|||||||
linkThread.name = "punktfunk-stage4-link"
|
linkThread.name = "punktfunk-stage4-link"
|
||||||
linkThread.qualityOfService = .userInteractive
|
linkThread.qualityOfService = .userInteractive
|
||||||
linkThread.start()
|
linkThread.start()
|
||||||
|
}
|
||||||
|
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
defer { renderStopped.signal() }
|
||||||
|
// Whether startLink ran — render-thread confined (only this thread triggers it).
|
||||||
|
var linkLive = false
|
||||||
// Per-iteration autorelease pool — same contract as the arrival/glass loop (the
|
// Per-iteration autorelease pool — same contract as the arrival/glass loop (the
|
||||||
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
|
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
|
||||||
while !token.isStopped { autoreleasepool {
|
while !token.isStopped { autoreleasepool {
|
||||||
@@ -1026,6 +1037,11 @@ public final class Stage2Pipeline {
|
|||||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||||
isHDR: planes.pq)
|
isHDR: planes.pq)
|
||||||
}
|
}
|
||||||
|
// First frame: the layer now has a real config — start vending (see startLink).
|
||||||
|
if !linkLive {
|
||||||
|
linkLive = true
|
||||||
|
startLink()
|
||||||
|
}
|
||||||
guard let drawable = stash.take() else {
|
guard let drawable = stash.take() else {
|
||||||
// No vend yet (session start: the reconcile above just unblocked the
|
// No vend yet (session start: the reconcile above just unblocked the
|
||||||
// allocator, the link's next update delivers; steady state: decode beat the
|
// allocator, the link's next update delivers; steady state: decode beat the
|
||||||
|
|||||||
@@ -65,19 +65,21 @@ public enum Stage444Probe {
|
|||||||
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false }
|
||||||
|
|
||||||
var produced: OSType = 0
|
var produced: OSType = 0
|
||||||
let done = DispatchSemaphore(value: 0)
|
// SYNCHRONOUS decode — no `._EnableAsynchronousDecompression`, so the output callback
|
||||||
|
// runs on THIS thread before DecodeFrame returns. The async flag + semaphore wait it
|
||||||
|
// replaced tripped the Thread Performance Checker on every first connect: VideoToolbox's
|
||||||
|
// callback thread carries no QoS class, and the userInteractive connect Task blocked on
|
||||||
|
// it through the semaphore (a priority inversion). A one-shot 256×256 probe gains
|
||||||
|
// nothing from decode parallelism; the lazy statics still cache the result.
|
||||||
let status = VTDecompressionSessionDecodeFrame(
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
session, sampleBuffer: sample,
|
session, sampleBuffer: sample,
|
||||||
flags: [._EnableAsynchronousDecompression], infoFlagsOut: nil
|
flags: [], infoFlagsOut: nil
|
||||||
) { status, _, imageBuffer, _, _ in
|
) { status, _, imageBuffer, _, _ in
|
||||||
if status == noErr, let imageBuffer {
|
if status == noErr, let imageBuffer {
|
||||||
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
produced = CVPixelBufferGetPixelFormatType(imageBuffer)
|
||||||
}
|
}
|
||||||
done.signal()
|
|
||||||
}
|
}
|
||||||
guard status == noErr else { return false }
|
guard status == noErr else { return false }
|
||||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
|
||||||
_ = done.wait(timeout: .now() + 1.0)
|
|
||||||
return produced == want || produced == fullRangeSibling
|
return produced == want || produced == fullRangeSibling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user