Sender: fix long-session stutter — real-time PTS + move video pipeline off the main thread#82
Merged
Merged
Conversation
Adds three user-controllable options in the sender GUI to avoid the "stutter that needs an app restart" symptom after the screensaver or display sleep activates while streaming. - Settings toggle "Prevent screensaver / display sleep while streaming" (default on, persisted) gates passing `.idleDisplaySleepDisabled` to `ProcessInfo.beginActivity`, so the screensaver/display-sleep does not engage during an active session. - Settings toggle "Auto-restart capture after wake / unlock" (default on, persisted) enables observers on `screensDidWake`, `com.apple.screenIsUnlocked`, and `com.apple.screensaver.didstop`. When fired, the capture pipeline (SCStream/CGDisplayStream + VTCompressionSession + capture timers + activity) is torn down and rebuilt while keeping the network connection and the virtual display alive. The PTS sequence counter is reset so the receiver's pacing does not desync after the gap. - Per-session "Restart capture" button (enabled while streaming) runs the same soft-restart path on demand for any other stutter cause. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t toggles off After field testing, the prior toggles did not address the long-running stutter (which is duration-driven, not screensaver-driven). Updates: - Default both "Prevent screensaver / display sleep" and "Auto-restart capture after wake" to OFF. Both remain available for users who find them useful; existing UserDefaults values are preserved. - Derive the extended-desktop virtual display productID/serialNumber deterministically from the receiver identity (name + panel dimensions) instead of randomizing each session. macOS keys window-position memory on these fields, so the random identity caused windows on the extended display to be forgotten on every reconnect. Same receiver now produces the same virtual display identity across reconnects, which lets the system restore prior window placement and matches the identity convention already used for the saved arrangement key. - Add an opt-in "Log virtual display events to Console (verbose)" toggle (default off). When enabled, registers a CGDisplayRegisterReconfigurationCallback that logs every add / remove / mirror / mode-change event affecting any display, and emits a periodic (60 s) stream snapshot to NSLog with streaming state, FPS, virtual display online status, pending packet count, in-flight encode count, and PTS sequence. Intended for diagnosing the long-duration stutter from Console.app without leaving heavy logging on for ordinary users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field diagnostics (24h run, sample + 'log show') showed the long-duration stutter is caused by macOS's replayd daemon issuing RPDaemonProxy: connection INTERRUPTED roughly every 30 minutes (sandbox extension refresh). The interruption does not reliably surface through SCStreamDelegate.didStopWithError, so the existing pipeline kept running in a degraded state until the main thread eventually wedged in recursive AppKit constraint layout. Three changes: 1) Frame-arrival watchdog New 5s repeating Timer on TBDisplaySenderSession that checks lastCaptureFrameAt (bumped on every encode() / encodeDisplaySurface() call). If isStreaming is true and no frames have arrived in >=8s, logs the gap and reuses the existing scheduleCaptureRestart() path to soft-restart only the capture pipeline. The NWConnection and the virtual display are preserved. Started after startFPSTimer() in both startCapture() paths; torn down in stop() and at the top of softRestartCapture(). This would have caught all 14 replayd interruptions observed in the diagnostic run. 2) Remove redundant objectWillChange.send() in the manager @published fields auto-emit on every change. The didSet handlers and the discovery / addSession / removeSession / refreshBridge / applyDiscoveredReceiver paths were also calling objectWillChange .send() explicitly, producing two notifications per change. The bubble-up sink in attachSession is preserved — it is the only non-redundant path (session @published -> manager objectWillChange). 3) Defer the discovered-receiver picker mutation .onChange(of: session.selectedReceiverID) was calling service.applyDiscoveredReceiver synchronously from within the view-update phase, producing the well-known SwiftUI runtime warning 'Publishing changes from within view updates is not allowed' (observed 16 times in the first 12 s of every launch). The mutation is now dispatched to the main queue so it runs after the current view update completes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tutter)
The extended-desktop capture path (CGDisplayStream) stamped each frame's
presentation timestamp from a frame counter scaled by the nominal frame
rate:
displayStreamFrameSequence += 1
let pts = CMTime(value: displayStreamFrameSequence,
timescale: Int32(capturePreset.expectedFrameRate))
But CGDisplayStream delivers frames irregularly (event-driven on screen
changes), so the synthetic PTS clock drifts away from real wall-clock
time and the drift compounds over a session. The receiver paces playback
by PTS, so after hours of streaming the cursor and video progressively
stutter. It only cleared on a *sender* restart (counter resets to 0),
not a receiver restart, and frames kept flowing throughout — which is
exactly what was observed in the field, and why the no-frames watchdog
never tripped.
Fix: derive PTS from the frame's actual capture time. CGDisplayStream's
displayTime is in mach-absolute units, the same host clock the SCStream
path already feeds via CMSampleBufferGetPresentationTimeStamp, so the
two capture paths and the receiver now agree on timestamp semantics:
var pts = displayTime != 0
? CMClockMakeHostTimeFromSystemUnits(displayTime)
: CMClockGetTime(CMClockGetHostTimeClock())
A monotonic guard nudges PTS forward if two frames ever share a
displayTime, since VTCompressionSession requires strictly increasing
timestamps. lastEncodedDisplayPTS is reset wherever the capture pipeline
restarts (stop, startDirectDisplayStream, softRestartCapture).
The frame counter is retained only for the verbose diagnostic snapshot.
The SCStream path was already correct and is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner
|
Hi @DrDavidL, is it possible to move this fix to feature/input-dockstation? Please, the next fix will be made on this branch until it's merged into the main branch. Thank you so much. |
Field diagnostics (live `sample` of the running sender while a screensaver ran on the extended display) showed two distinct causes of the "stutters with extended use" symptom, neither addressed by the earlier PTS fix: 1) The whole capture→encode→send pipeline ran on the main thread. TBDisplaySenderSession is @mainactor, and every frame made three main-thread hops (intake, encode-complete, send-complete). When the Session Monitor window was open its SwiftUI hosting view re-laid-out continuously (~18% of the main thread in recursive AppKit constraint layout). Those layout bursts blocked the main thread, frame work queued behind them, and the pendingVideoPackets>=3 cap then dropped frames — visible as cursor/video stutter, worst during a screensaver (continuous frames). Confirmed: closing the window made it smooth and raised encoder throughput ~40%. Fix: extract TBVideoPipeline, which owns the VTCompressionSession, the pending/in-flight counters, packet building and sending, and runs entirely on a dedicated serial queue (fd.tbmonitor.sender.pipeline). Encoder setup/teardown are serialized on that queue, so a frame can never encode into an invalidated session. Both capture paths (CGDisplayStream + SCStream) feed the queue directly. The main actor keeps only UI/lifecycle state and reads the two values it polls (sentFrames for FPS, lastCaptureFrameAt for the watchdog) under a small lock — no per-frame hop to main. The real-time PTS logic is preserved unchanged. Measured: encode now runs off-main; main-thread layout dropped from ~18% to ~1% with the window open. 2) senderFPS was @published on the session and rewritten every second by the FPS timer. Via the manager's objectWillChange bubble-up this re-rendered the entire window ~1.8x/sec (full constraint relayout) whenever the Session Monitor window was open. Fix: move senderFPS to a dedicated TBSessionLiveMetrics observable, displayed by an isolated SessionMonitorFPSRow subview, so the 1 Hz tick re-renders just that row. Measured: card/window re-renders dropped from 1.82/sec to 0/sec at idle and during a screensaver, with the window open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced May 30, 2026
Crash report (24h session, EXC_BAD_ACCESS at 0x10) faulted on the fd.tbmonitor.sender.pipeline queue inside SkyLight's _CGYDisplayStreamFrameAvailable, while the main thread was concurrently in softRestartCapture(for:) tearing the pipeline down. The screensaver's autoRestartOnWake fires a soft restart on every display-sleep/wake, so the teardown path ran many times over the session and eventually raced a frame callback. Two lifetime bugs in TBDirectDisplayStreamCapture, both introduced when the encode pipeline moved off the main thread: 1) stop() called CGDisplayStream.stop() then immediately released the stream (`stream = nil`). CGDisplayStreamStop is asynchronous — frames already in flight keep arriving until the stream delivers a final `.stopped` frame — so releasing it early let a queued frame event fire into a freed stream, crashing inside SkyLight. 2) The frame handler dereferenced the TBVideoPipeline via an *unretained* pointer, while softRestartCapture/stop set `pipeline = nil` concurrently — a second use-after-free window. Fix: - Hold the pipeline with a strong reference so it (and its delivery queue) outlives every frame callback; combined with the existing `running` guard in encodeDisplaySurface, a late in-flight frame after pipeline.stop() simply no-ops. - Keep the capture object and its CGDisplayStream alive from stop() until the stream delivers `.stopped` (a self-retain released in the handler), honoring CGDisplayStream's documented async-stop contract so the stream is never freed with frame events still queued. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The actual fix for the long-session cursor/video stutter that #69 and #81 were chasing. Multiple independent causes were confirmed by live
sampleof the running sender; this PR addresses all of them.There are four parts, found in order:
Part 1 — Real-time PTS (timestamp drift)
The extended-desktop capture path uses
CGDisplayStream, and stamped each frame's PTS from a frame counter scaled by the nominal frame rate:CGDisplayStreamdelivers frames irregularly — event-driven on screen changes, not a fixed cadence. Stamping them as if exactly1/fpsapart makes the synthetic PTS clock drift from wall-clock time, compounding over the session. The receiver paces by PTS, so after a few hours the cursor/video progressively stutter. Cleared by a sender restart (counter resets), not a receiver restart; frames keep flowing throughout (so #81's no-frames watchdog correctly never fired).Fix: derive PTS from the frame's actual capture time.
displayTimeis in mach-absolute units — the same host clock theSCStreampath already uses — so both capture paths and the receiver agree on timestamp semantics:Part 2 — Move the video pipeline off the main thread
Further field diagnostics (live
samplewhile a screensaver ran on the extended display) showed a second cause the PTS fix didn't cover.TBDisplaySenderSessionis@MainActor, and the whole capture→encode→send pipeline ran on the main thread — three main-thread hops per frame (intake, encode-complete, send-complete). With the Session Monitor window open, its SwiftUI hosting view re-laid-out continuously (~18% of the main thread, recursive AppKit constraint layout). Those layout bursts blocked the main thread; per-frame work queued behind them and thependingVideoPackets >= 3cap then dropped frames → stutter, worst during a screensaver (continuous frames).Fix: extract
TBVideoPipeline, which owns theVTCompressionSession, the pending/in-flight counters, and packet building/sending, and runs entirely on a dedicated serial queue (fd.tbmonitor.sender.pipeline). Encoder setup/teardown are serialized on that queue, so a frame can never encode into an invalidated session. Both capture paths (CGDisplayStream + SCStream) feed the queue directly. The main actor keeps only UI/lifecycle state and reads the two values it polls (sentFrames,lastCaptureFrameAt) under a small lock — no per-frame hop to main. The Part 1 PTS logic is preserved unchanged.Measured: encode now runs off-main (
fd.tbmonitor.sender.pipeline, confirmed insample); main-thread layout dropped from ~18% → ~1% with the window open.Part 3 — Isolate the FPS readout (the relayout loop noted as out-of-scope on the first pass)
The earlier "continuous SwiftUI re-layout" note is now fixed.
senderFPSwas@Publishedon the session and rewritten every second by the FPS timer; via the manager'sobjectWillChangebubble-up this re-rendered the entire window ~1.8×/sec whenever the Session Monitor window was open.Fix: move
senderFPSto a dedicatedTBSessionLiveMetricsobservable, displayed by an isolatedSessionMonitorFPSRowsubview, so the 1 Hz tick re-renders just that row.Measured (window open, instrumented with
Self._printChanges()): card/window re-renders dropped from 1.82/sec → 0/sec, both at idle and during a screensaver.Part 4 — Fix a CGDisplayStream teardown use-after-free
A 24 h soak crashed (
EXC_BAD_ACCESSat0x10): the faulting thread was onfd.tbmonitor.sender.pipelineinside SkyLight's_CGYDisplayStreamFrameAvailable, while the main thread was concurrently insoftRestartCapture.autoRestartOnWakefires a soft restart on every display-sleep/wake, so the teardown path runs often during a screensaver and eventually raced a frame callback. Two lifetime bugs inTBDirectDisplayStreamCapture, both introduced by Part 2:stop()released theCGDisplayStreamimmediately.CGDisplayStreamStopis asynchronous — in-flight frames keep arriving until the stream delivers a final.stoppedframe — so a queued frame event fired into a freed stream.pipeline = nilconcurrently.Fix: hold the pipeline with a strong reference so it (and its delivery queue) outlives every callback — combined with the
runningguard, a late in-flight frame afterpipeline.stop()simply no-ops; and keep the capture object + stream alive fromstop()until the.stoppedframe arrives (self-retain released in the handler), honoringCGDisplayStream's documented async-stop contract so the stream is never freed with events still queued.Test plan
TBDisplaySenderscheme.sampleconfirms encode runs onfd.tbmonitor.sender.pipeline, not the main thread.🤖 Generated with Claude Code