Skip to content

Sender: fix long-session stutter — real-time PTS + move video pipeline off the main thread#82

Merged
swellweb merged 6 commits into
swellweb:mainfrom
DrDavidL:codex/realtime-pts
May 31, 2026
Merged

Sender: fix long-session stutter — real-time PTS + move video pipeline off the main thread#82
swellweb merged 6 commits into
swellweb:mainfrom
DrDavidL:codex/realtime-pts

Conversation

@DrDavidL
Copy link
Copy Markdown
Contributor

@DrDavidL DrDavidL commented May 28, 2026

Summary

The actual fix for the long-session cursor/video stutter that #69 and #81 were chasing. Multiple independent causes were confirmed by live sample of the running sender; this PR addresses all of them.

Supersedes #69 and #81 — this branch contains their commits, so they've been closed and the whole fix lands here as one PR. Branches off the current main (bb457f3) and merges cleanly with no conflicts.

There are four parts, found in order:

  1. Real-time PTS for the CGDisplayStream path (timestamp drift).
  2. Move the video pipeline off the main thread (UI layout starving frame delivery).
  3. Isolate the FPS readout (1 Hz full-window relayout).
  4. Fix a CGDisplayStream teardown use-after-free surfaced by Part 2 (24 h soak crash).

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:

displayStreamFrameSequence += 1
let pts = CMTime(value: displayStreamFrameSequence, timescale: Int32(capturePreset.expectedFrameRate))

CGDisplayStream delivers frames irregularly — event-driven on screen changes, not a fixed cadence. Stamping them as if exactly 1/fps apart 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. displayTime is in mach-absolute units — the same host clock the SCStream path already uses — so both capture paths and the receiver agree on timestamp semantics:

var pts = displayTime != 0
    ? CMClockMakeHostTimeFromSystemUnits(displayTime)
    : CMClockGetTime(CMClockGetHostTimeClock())
if let last = lastEncodedDisplayPTS, CMTimeCompare(pts, last) <= 0 {
    pts = CMTimeAdd(last, CMTime(value: 1, timescale: 600))  // strictly increasing PTS for VTCompressionSession
}

Part 2 — Move the video pipeline off the main thread

Further field diagnostics (live sample while a screensaver ran on the extended display) showed a second cause the PTS fix didn't cover. TBDisplaySenderSession is @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 the pendingVideoPackets >= 3 cap then dropped frames → stutter, worst during a screensaver (continuous frames).

Confirmed: closing the window made it smooth and raised encoder throughput ~40% — pinpointing the main thread as the bottleneck.

Fix: extract TBVideoPipeline, which owns the VTCompressionSession, 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 in sample); 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. 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.8×/sec 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 (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_ACCESS at 0x10): the faulting thread was on fd.tbmonitor.sender.pipeline inside SkyLight's _CGYDisplayStreamFrameAvailable, while the main thread was concurrently in softRestartCapture. autoRestartOnWake fires 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 in TBDirectDisplayStreamCapture, both introduced by Part 2:

  1. stop() released the CGDisplayStream immediately. CGDisplayStreamStop is asynchronous — in-flight frames keep arriving until the stream delivers a final .stopped frame — so a queued frame event fired into a freed stream.
  2. The frame handler dereferenced the pipeline via an unretained pointer while teardown set pipeline = nil concurrently.

Fix: hold the pipeline with a strong reference so it (and its delivery queue) outlives every callback — combined with the running guard, a late in-flight frame after pipeline.stop() simply no-ops; and keep the capture object + stream alive from stop() until the .stopped frame arrives (self-retain released in the handler), honoring CGDisplayStream's documented async-stop contract so the stream is never freed with events still queued.


Test plan

  • Builds clean via the TBDisplaySender scheme.
  • sample confirms encode runs on fd.tbmonitor.sender.pipeline, not the main thread.
  • Main-thread layout ~1% (was ~18%) and re-renders 0/sec (was 1.82/sec) with the window open + screensaver running.
  • FPS readout still updates live; screensaver smooth with the Session Monitor window open.
  • Multi-hour Extended Desktop (CGDisplayStream) soak — confirm no drift-into-stutter and no teardown crash (the Part 4 regression manifested only after ~24 h of repeated display-sleep/wake soft-restarts).
  • Duplicate Desktop / SCStream path unchanged.
  • Connect/disconnect, cable test, and soft-restart (display sleep/wake) cycles behave; no encoder errors after stop/restart.

🤖 Generated with Claude Code

DrDavidL and others added 4 commits May 25, 2026 21:58
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>
@swellweb
Copy link
Copy Markdown
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>
@DrDavidL DrDavidL changed the title Sender: real-time PTS for CGDisplayStream capture (fix long-session stutter) Sender: fix long-session stutter — real-time PTS + move video pipeline off the main thread 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>
@swellweb swellweb merged commit 92ad238 into swellweb:main May 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants