Skip to content

Stream backpressure: degrade the frame rate, never storm the encoder (#597) - #613

Merged
iamfatness merged 37 commits into
mainfrom
feat/stream-backpressure
Sep 24, 2026
Merged

iamfatness merged 37 commits into
mainfrom
feat/stream-backpressure

Conversation

@iamfatness

Copy link
Copy Markdown
Owner

Closes #597. Also closes #603, which this branch un-deferred after making it worse.

The incident

A live YouTube stream at 10 Mbps H.264 stalled the operator's app for about twenty seconds. Two things were wrong at once: the destination could not keep up, and the encoder was torn down and rebuilt eight times while that happened.

What this does

Measures how far behind a destination is, as the wall-clock age of the oldest queued chunk. Never a frame count converted to milliseconds, because that conversion is wrong precisely while the frame rate is being changed underneath it.

Two levers. One feeds the encoder fewer frames so the queue stops growing. The other discards the backlog to a keyframe, recovering latency already lost. Per-frame quality is untouched by both.

Publishes all of it at outputSenderSession.senders[].backpressure and realtimeEvidence.encoderExport, so a stream quietly running at a reduced rate is visible rather than inferred.

Bounds the restarts. A destination cannot be rebuilt faster than its backoff rung, on either of the two restart authorities that exist.

Gates it. node scripts/validate-gpu-encode.mjs --seconds 240 --slow-sink --burst-sink runs a real congested destination and fails unless the stream degrades without the encoder being rebuilt.

Four things the plan got wrong, found by measurement

The throttle was specified in the wrong place. Skipping only the sender's submit left the stream byte-identical; the encoder advances on the keyed mutex when the compositor releases a frame. Halving the export rate halved egress.

The root cause of the restart storm was not what the plan assumed. There were two restart authorities, and the ladder was never bypassed — it was never consulted, because the sender adapter re-opened its own transport under a separate one-second backoff that reset on the first accepted frame.

Backpressure's own last-resort bound called the storm: the queue's overflow path failed the sender on purpose so the supervisor would rebuild the encoder. It now discards the backlog instead.

The plan named a snapshot path that does not exist, which the gate would have read as a missing node.

Reviewer attention, in order

The three discard paths in RtmpOutputSenderAdapter and StreamBackpressurePolicy. Every one must leave the decoder resuming at a self-contained keyframe.

The stand-off that stops a backing-off destination being escalated to give-up. It needed two changes, not one — classifying the backoff correctly still leaves the staleness and start-grace doors open, which a review verified by reverting half the fix and watching a congested stream die in under three minutes.

The gate's own assertions. Four measuring instruments in this work could report success while measuring nothing, and one of those we introduced ourselves while fixing another.

Verification

Full native suite 1236 passed, 0 failed, on the merged tree with main. Stub configuration builds and its gate is green. Congestion gate passes with both levers firing, program holding 60 fps, and the incident's own signature clean.

Not proven, stated plainly

Six-plus clean gate runs are a finite soak, not a guarantee — the failure this guards against appeared roughly one run in three before the fix.

Restart-ladder rung 1 is pinned only by unit tests.

The HEVC overflow branch has never fired, because the queue's cap counts chunks while the throttle limits arrival rate (#607).

HEVC discards were observed not to corrupt the RTMP/FLV path — decodability, not A/V sync. The MPEG-TS/SRT case, where an unsignalled clock jump was the actual concern, remains unobserved because the congestion harness runs on an RTMP sink.

One thing to decide before shipping

This branch introduces a new silent degradation mode. A show can go out at a quarter frame rate, correctly and deliberately, with nothing in the operator console saying so. The data is published; no interface binds to it. #610 tracks that readout.

Filed, not fixed here

#601 (the encoder ignores its configured bitrate — measured about 59.5 Mbps at both 10000 and 2000 kbps, which may be this incident's real trigger and which backpressure now masks), #602, #604, #605, #606, #607, #610, #611, #612.

🤖 Generated with Claude Code

…ncoder

#597: a 10 Mbps H.264 YouTube stream produced a ~20s operator stall. The logs
show the stream ran healthily for 96s (ffmpeg speed=1x), then slipped under
real time, hit the compressed-video queue bound, and the core rebuilt the
hardware encoder eight times in twenty seconds, each rebuild pushing a fresh
~75KB keyframe into an already-backed-up pipe. perf.log proves the UI thread
was never blocked: across the 21.01s gap the sample counter advanced normally
and per-sample dispatch stayed 0.0ms, so the core's snapshot emission fell to
~26% while it did the rebuilds.

Compressed frames cannot be dropped, so the throttle has to act before the
encoder. Owner rulings 2026-09-23: drop frame rate first and keep quality;
backpressure ships on its own slice. Design mirrors the proven
MonitorShedPolicy shape: queue utilisation drives an input divisor
(60/30/20/15 fps), enter fast, recover slowly, hysteresis band between.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Architecture only; OBS is GPL and none of its code is copied (EncoderPolicy.h
standing rule). Four changes:

- Corrects a wrong claim. The first draft said compressed frames cannot be
  dropped. OBS drops them by priority under congestion. Our own B-frames-off
  configuration leaves two tiers, so our safe discard is a GOP tail: drop the
  queued chunks before the next keyframe, which skips instead of corrupting.
- Adds a second lever. Throttling inflow stops the queue growing but never
  clears the ~1s already in it; a stream could stabilise a second behind and
  stay there. Backlog discard recovers that latency, and engages only above the
  higher threshold because it costs a visible skip.
- Changes the measure to buffered milliseconds, taken as the wall-clock age of
  the oldest queued chunk. Percent-of-bound shifted meaning with bitrate and
  resolution, and a frames-to-ms conversion would lie precisely while the frame
  rate is being changed underneath it.
- States encoder/connection separation as a design goal: a network fault
  restarts the connection and muxer and asks the running encoder for a keyframe,
  and must never rebuild the encoder.

Also records what does not transfer: OBS measures its own socket send queue,
while we hand bytes to FFmpeg, so our latency measure understates the truth.
That makes the deferred egress-based health signal more urgent, not less.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 1 is a measurement that gates the rest: if halving the encoder's input
rate does not roughly halve egress, Lever A cannot work as designed and the
alternative contradicts the owner's "keep quality" ruling, so it stops and
asks rather than substituting.

Then the pure policy, the queue's buffered-latency telemetry, the two levers,
the snapshot node, the restart floor, a slow-sink acceptance gate, and docs.
The gate asserts the #597 signature specifically: the core's snapshot cadence
measured against the sample counter, which is what distinguishes a producer
slowdown from a UI freeze.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…r A)

Measured on the real MF hardware H.264 encoder at 1920x1080@60, 10000 kbps:
60 input frames/s emits 74.40 MB per 10 s, 30 input frames/s emits 37.26 MB
- ratio 0.500/0.501 over two runs, bits per frame constant (~124 KB).

Two findings recorded in the test header and the task report: submit() does
not pace the encoder (the compositor's keyed mutex does, so the throttle must
skip the program frame, not the submit call), and the configured bitrate does
not bind today (10000 and 2000 kbps both emit ~59.4 Mbps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ubmit

Task 1 measured that skipping only submitFrameToGpuEncoder leaves the stream
byte-identical (ratio 0.998) — the encoder advances on the keyed mutex when the
compositor releases a frame. Halving the export rate halves egress (0.500). The
plan's gate location was wrong; the divisor now travels sender -> MediaCore ->
compositor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mit path

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own half

Review finding on Task 2. AnUnknownMeasurementIsIgnored runs entirely at
divisor 1, where a negative measurement also falls below the throttle threshold
and takes the divisor_ == 1 short-circuit — so deleting the bufferedMs < 0 guard
left it green. Mutation-proved: with the guard removed the old test still passes
and the new AnUnknownMeasurementBreaksNeitherStreak fails, which is the #481
rule (a test that cannot construct the regression cannot catch it).

Also renamed SustainedBacklogStepsDownOneLevelAtATime to ...StepsUp..., which is
what it asserts, and added RecoveryStepsDownOneLevelAtATime for the step-down
half nothing covered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 1 measured that gating the sender's submit() changes nothing (the stream
came out byte-identical, ratio 0.998): the hardware encoder's thread advances
on the KEYED MUTEX of the encoder shared texture, so it only moves when the
compositor releases a new frame. Halving the EXPORT rate halved egress (0.500).
The throttle therefore lives in the compositor, and the divisor travels
sender -> MediaCore -> compositor.

- Interfaces.h: OutputBackpressureState{divisor} + an optional field on
  OutputSender (absent = no queue to observe, NOT "healthy");
  ICompositor::setEncoderExportDivisor(int) as a default no-op so Metal and the
  stub are unaffected; a test-only observation seam on IOutputSender.
- D3D11CompositorAdapter: gate exportEncoderSharedTexture on
  frameNumber % divisor, relaxed atomic written from the output tick.
- RtmpOutputSenderAdapter: observe the bitstream queue once per sync via
  StreamBackpressurePolicy and publish the divisor; one log line per transition.
- MediaCore: take the MAX divisor across active GPU-direct senders and call
  setEncoderExportDivisor only when it CHANGES, at both sync() call sites.

MAX, not per destination: one encoder texture feeds every GPU-direct sender, so
a healthy sibling IS throttled by a struggling one. Known limitation of this
slice, documented at every site; Lever B (the queue discard) stays per
destination.

Tests pin the decision where it is applied, not in the leaf policy:
RtmpOutputSenderBackpressure.TheDivisorGatesTheEncoderTextureExport drives the
real D3D11 compositor for 200 renders and asserts an exact 100/100 split;
TheSendersDivisorReachesTheCompositor drives the real sender's sync() to a
published divisor > 1 and asserts MediaCore carries the max, once per change.
Both proved RED first, including a mutation run with the plumbing removed.
Full suite 1189 passed, 0 failed (baseline 1187).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lever A's gate moved into the compositor in Task 4, so frames are shed once for
every GPU-direct destination rather than once per sender. A per-sender
shedFrames would read as each destination having shed its own; the value the
sender's policy can actually report is 0. The count moves to
realtimeEvidence.encoderExport alongside the effective divisor, and the policy's
now-uncallable noteShedFrame()/shedFrames() are deleted there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of 6125ed2 found three on-air risks and it is right on all three.

1+2 (blocking) A shed frame left frame.encoderSharedTexture empty.
  resolveGpuEncodePath reads an absent handle as CpuFallback, gpuPathChanged
  fires in ensureFfmpegProcess, and FFmpeg plus the hardware encoder are torn
  down and relaunched - once per shed frame, tens per second, where #597 itself
  was eight restarts in twenty seconds. For H.265 the relaunch is admitted off
  the GPU path, StreamStartAdmission refuses it, startRefusedInadmissible_
  latches, and the stream is dead for the rest of the show.
  exportEncoderSharedTexture now takes submitPixels and its two jobs are split:
  the submit (the keyed-mutex release, which is what paces the encoder and what
  Task 1 measured) is shed; the handle metadata is published on EVERY frame,
  because a shed frame has not changed whether a GPU encoder texture exists.
  encoderSharedTexture.frameNumber now carries the last number actually
  submitted, tracked on the render thread - NOT publishedFrameNumber(), which
  the exporter's own thread stores asynchronously and which therefore cannot
  answer "did this render submit?".

3 The divisor latched across a stream stop. The !wantsRtmp path now resets the
  policy and clears sender_.backpressure, and applyEncoderExportDivisor skips
  stopped/idle senders - so the compositor cannot stay throttled with nothing
  streaming, and the next stream does not open at 15 fps on an empty queue.
  warning/failed still count: such a destination may still be GPU-direct with a
  backed-up queue.

5 lastEncoderExportDivisor_ is std::atomic<int>: it is written from the
  video-output thread and the audio worker's fallback, mutually exclusive only
  by a runtime flag.

7 The observation site now states that the policy ticks at the RENDER rate, so
  kEnterAfterOverWaterTicks is ~0.5s at 60Hz whatever the declared stream fps.

4+6 Tests. New RtmpOutputSenderBackpressure.AShedFrameNeverRestartsTheSendersEncodePath
  drives a REAL RtmpOutputSender against a REAL D3D11 compositor at divisor 2 -
  the one combination nothing covered, and the whole of findings 1-3 - and
  counts encode-path flips over 40 frames, asserting zero. TheDivisorGates...
  now also asserts the handle is present on every frame and that a shed frame
  never publishes a number the encoder was not given. TheSendersDivisor... now
  covers the stop reset, an absent-backpressure sender and a stopped sender.
  All three went RED against 6125ed2 first, one failure per finding.

Filtered run only (3/3 passed, three consecutive runs): the owner's app is live
with the virtual camera on, so the full suite is deferred per CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both are one-liners in files Task 6 already opens: reset the last-submitted
encoder frame number when the exporter is recreated, and name the inert
stop-path divisor residual at the call site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… keyframe

Task 4 (Lever A) throttles the encoder's input so the outgoing bitstream
queue stops growing, but it never clears what is already queued, so a
stream can stabilise a full second behind and sit there. This adds
RtmpOutputSender::discardBacklogToNextKeyframe(), driven from
observeStreamBackpressure() (called from sync()) whenever the policy
raises decision.discardBacklog: it drops every chunk strictly ahead of
the next queued keyframe, never the keyframe itself, and republishes
queue telemetry so bitstreamBufferedMs() stops reporting the age of a
chunk that no longer exists. With no keyframe queued nothing is dropped,
since HEVC/AV1 here run with B-frames disabled and every frame after a
keyframe is a reference frame.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 5 leaves backpressureDiscardedChunks_ write-only. Publishing it is half
the job; per the per-stream-run ruling it must reset on the stop path beside
the policy, or a new run reports the previous show's discards as its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sted

Task 5's review found a stream-corruption risk it could not settle statically:
the discard assumes every CleanPoint chunk is a self-contained IDR with in-band
parameter sets, and nothing configures that. Blocking evidence step added to the
live gate, with the mitigation being to make the IDR self-contained rather than
to teach the discard about headers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cessor

Review findings on the discard mechanics itself were clean (loop boundary
conditions, queue accounting, concurrency); this round addresses task
quality:

- Extract the discard decision into core::discardableGopTailLength(), a
  pure header-only function in StreamBackpressurePolicy.h testable with a
  bare vector<bool> and no sender, no queue, no seam at all.
  discardBacklogToNextKeyframe() is now a four-line locked wrapper around
  it. IOutputSender's test-only surface shrinks from 5 new virtuals to 2
  (enqueueBitstreamChunkForTest, bitstreamQueueSnapshotForTest).
- Use size() as the "not found" sentinel so "no keyframe queued" and
  "keyframe already at the head" are distinct, falsifiable cases instead
  of collapsing onto the same tautological zero.
- Add a call-site test that builds a real backlog on the real queue,
  drives the decision through observeStreamBackpressure()/sync(), and
  asserts the real queue actually shrank - closing the "delete the whole
  if-block and every test stays green" gap.
- Reset backpressureDiscardedChunks_ on the per-stream-run stop path
  beside the policy reset it belongs next to.
- Guard the observeStreamBackpressure() call site in sync() with
  #if defined(_WIN32), matching the function's own guard (pre-existing
  POSIX compile gap from Task 4, fixed here).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 5 re-review residuals. The two remaining test-only virtuals are acceptable
because no wrapper forwards them AND their zero defaults make a test's
precondition assert fail loudly rather than pass with nothing under test — but
that reasoning has to be written down, with the instruction to move them behind
a narrow interface if a third is ever wanted. Also pins [P,P,P,K] -> 3, the
maximum-drop case every other test misses, and includes <cstddef> for the
std::size_t the seam now names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ferred

Task 6: OutputSender.backpressure gains the full per-destination view
(level/bufferedMs/queuedChunks/enteredCount/discardedChunks/discardEvents/
lastReason/lastTransitionBufferedMs), and realtimeEvidence.encoderExport
publishes the ONE effective divisor and shed-frame count the compositor
actually applies, unconditionally - a divisor of 1 and 0 shed frames is
the healthy reading, not an absent node.

Per the amendments: shedFrames left the per-sender struct (the compositor
sheds once, globally, not per destination); noteShedFrame()/shedFrames()/
shedFrames_ are deleted from StreamBackpressurePolicy as uncallable by
construction, and their test is renamed to EnteredCountIsCounted.
lastSubmittedEncoderFrameNumber_ now resets when encoderExport_ is
recreated, and the inert stop-path divisor residual is named in a comment,
not guarded.

Adds the regression test the brief calls out as the actual gap: the
discard-counter reset (already landed in an earlier Task 6 amendment) was
unobservable and therefore untested until this task published the field.
ADiscardedChunkCounterResetsOnTheNextStreamRun pins it - verified RED
(failing at the exact assertion) with the reset temporarily disabled, then
GREEN with it restored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Verified against the running core: sessionState() publishes outputSenderSession
.senders[], and there is no outputSenders node. The plan and spec both named a
path that does not exist, which Task 8's gate would have read as 'node missing'
indistinguishably from the node genuinely being absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g flag

Fixes every TASK QUALITY finding from the review:

- Finding 1 (HIGH): observeStreamBackpressure() now gates on
  activeUseGpuDirect_ (the path baked into the RUNNING FFmpeg args) instead
  of useGpuDirect_ (only the desired path) - a destination whose FFmpeg
  failed to start no longer publishes a pristine healthy node.
- Finding 4/9: the applyEncoderExportDivisor stop-path residual comment is
  moved to the return it describes and its substance corrected (publishing
  the divisor made it visible, not inert); encoderExport gains `exporting`
  so a stale divisor between shows is distinguishable from a live throttle.
- Finding 5/7: the reset test now pins all five per-run counters, not just
  discardedChunks.
- Finding 6/11: OutputBackpressureState gains `runId`, minted on every
  policy reconstruction, so a reset is distinguishable from a counter going
  backwards across a missed poll; shedFrames' process-cumulative nature is
  now documented against its per-run siblings.
- Finding 7/2: bufferedMs is now re-read after a same-tick discard so it and
  queuedChunks describe the same instant.
- Finding 8/3: adds observedAtMs so a frozen node (sync()'s early-return
  paths) is distinguishable from a fresh one.
- Finding 9/4,5,10: documents discardedChunks vs discardEvents, drops the
  per-tick lastReason string allocation (const char*), saturates
  backpressureDiscardedChunks_.
- Finding 3: writes and (once the testing constraint lifted) RUNS the
  declared coverage gap - MediaCoreCommandTest.cpp gains direct assertions
  on realtimeEvidence.encoderExport and outputSenderSession.senders[].backpressure.
  The second test was genuinely RED on first run (runId/observedAtMs were
  missing from MediaCore.cpp's JSON emission) and is GREEN after the fix.

Full unfiltered corevideo-native-tests.exe run: 1202 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ting, fix the divisor latch

- Item 1: adds RtmpOutputSenderBackpressure.AGpuDirectRequestRefusedForCompatibilityPublishesNoBackpressure,
  reproducing the actual finding-1 defect through the ordinary sync() path with
  no injection, no reorder, no new seam - a GPU-direct request refused for
  H.265-without-Enhanced-RTMP leaves useGpuDirect_ true and activeUseGpuDirect_
  false with no running FFmpeg. Verified RED (gate reverted to useGpuDirect_,
  reason=gpu-direct measured) then GREEN.
- Item 2: corrects all three comment copies claiming divisor>1 + exporting
  means a genuine live throttle. fullProgramReadback is vcam-OR-output-OR-
  recording, so exporting reads true with only the virtual camera on and no
  stream at all; the comments now say what the flag actually answers.
- Item 4: renderVideoOutputTick now pushes an explicitly empty
  OutputSenderSession into applyEncoderExportDivisor on the tick
  senderDestinations goes empty, instead of trusting outputSender->sync()'s
  possibly-cached pre-stop snapshot on the last call it will ever receive.
  MediaCore's own senderDestinations list is authoritative and synchronous,
  so the divisor is corrected back to 1 instead of latching indefinitely
  between shows. No second reset path on the sender/policy side.
- Item 3: EncoderExportIsPublishedUnconditionallyOnAFreshStubCore's shedFrames
  assertion null-checks the key first (Json::getNumber's missing-key fallback
  is also 0, so EXPECT_EQ(...,0) alone couldn't catch a typo); audited every
  other new snapshot assertion for the same trap - none found.

Full unfiltered corevideo-native-tests.exe run: 1203 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 6 fix round 2 item 4 shipped the fix without a test, judged out of scope
because it needs a fake sender modelling AsyncOutputSender's cache. The existing
BackpressurePublishingSender already is one -- it reports its divisor forever,
destinations or not -- so the test costs nothing.

Mutation-proved: reverting renderVideoOutputTick to trust the sender's cached
snapshot fails it on the line that says nothing was pushed when the last
destination went away. Without this, a latched divisor plus an "exporting" flag
that is true whenever the vcam is on makes an idle machine read as throttled.

Full suite 1204 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rung (#597)

Step 1 found the ladder was not being bypassed - it was not being consulted.
Six of the eight encoder rebuilds in the incident window carry no
[outputSupervisor] restarting line at all: they came from
RtmpOutputSenderAdapter::ensureFfmpegProcess re-opening its own transport from
the media tick, under an adapter-local backoff whose first rung was ONE second
and whose streak was cleared by the FIRST accepted frame after a rebuild. Up,
one frame, dead, 1s, round again - the measured 2.5-3.5s cadence.

TransportRestartFloor (pure, in OutputDestinationSupervisorPolicy.h) is the
house 5/10/20/40/60s ladder in a form a second restart authority can hold, with
the budget returned only after kHealthyRunMs of a run accepting output. The
adapter now asks it at its single re-open gate, keyed on the elapsedMs clock
MediaCore already feeds it. recover() still clears the floor, so the output
supervisor stays the senior authority.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stic

Found by re-running the full suite independently: this failed about one run in
three (measured 2 of 3 in isolation), which is worse than no test.

It inferred "this frame was shed" from encoderSharedTexture.frameNumber not
advancing. That also happens when D3DDecoupledExport takes its own bounded
3-slot refusal, where no shed occurred -- so the test counted a refusal as a
shed and failed against a counter that was correct. A Lever A shed is decided by
the divisor and nothing else, so the expectation is now computed from it.

The Task 5 review warned about exactly this conflation and the sibling test
above already avoids it; this one reintroduced it.

8 of 8 consecutive runs green after the fix. Full suite 1208 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Finding 1, and it is the 21:09:10.088 line round 0 listed as Unsure.
OutputDestinationSupervisorPolicy::observe() zeroed nextAttemptAtMs_ on a
healthy INSTANT of a run past kHealthyRunMs without clearing faultPending_, so
the restart gate fired on the next 250ms tick against a 5000ms rung. `healthy`
comes from acceptedUnits freshness, not status, so a destination that fails
while its last accepted unit is under a second old is failed and healthy in the
same observation - the ordinary shape of a mid-show failure. The healthy-run
branch is now gated on !faultPending_.

Second half of the same finding: the supervisor's automatic restart went
through recover(), which cleared the adapter floor round 0 had just added, so
that route had no backstop at either level. IOutputSender gains
restartForSupervisor() - default forwards to recover(), forwarded explicitly by
AsyncOutputSender / CompositeOutputSender / SupervisedOutputSender per the
wrapper law - and the RTMP/SRT adapter keeps its floor for it while an operator
recover() still clears it.

Finding 2: the floor was exempt whenever the transport was running, which
gpuPathChanged / sizeChanged / audioChanged reach without any operator. The
exemption is now a settings apply, running or not.

Finding 3: the whole-decision test drove no supervisor despite its name. Split
into RtmpOutputSender.TransportRebuildsAreNeverCloserThanTheCurrentLadderRung
(adapter authority) and a new
OutputDestinationSupervisor.RestartsAreNeverCloserThanTheCurrentLadderRung over
the deterministic fault host, plus the pure policy property. Pre-fix both fail
at +250ms; post-fix the first restart lands on the 5000ms rung.

Finding 4: deleted the monotonic assertion that passed pre-fix on a 1ms margin.
Finding 6: named the outputElapsedMs epoch invariant at MediaCore.cpp:8229.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the wrapper law (#597)

Item 1. Round 1 narrowed the floor exemption to a settings apply, which made a
new state reachable: the gate can now refuse a rebuild while FFmpeg is still
running, and it returned before stopFfmpegProcess(). The destination then spent
up to a full rung with a live child fed nothing while publishing failed - and on
SRT the lingering child holds the single caller slot, so the reconnect at the
end of the rung is refused and the stream never comes back. The refusal path now
stops the child, stamps stoppedAtMs, and names the rung in its warning. The
alternative (exempt a running transport from the floor) was rejected: that is
exactly the rule round 1 removed, and gpuPathChanged flaps with the transport up
by definition.

Items 2 and 3. restartForSupervisor shipped with nothing that failed when it was
removed - FakeDestinationChild inherited the default forward, so the supervisor's
own restart and an operator recover() were indistinguishable. Now counted
separately and asserted at the decision site; a real-sender test pins that an
armed floor survives a supervisor restart and yields to an operator recover();
and the forwarding is pinned through AsyncOutputSender's queue and through the
production Composite -> Supervised -> Async stack. All five mutations fail.

Item 4: the invariant TransportRestartFloor::clear() rests on is declared at the
site. Item 6: one send-proof line per backoff window, not ~3,600 at rung 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round-2 re-review finding. The streak clamps at kMaxConsecutiveFailures, so the
"attempt K of 5" form publishes "attempt 6 of 5" forever once the ladder tops
out, and "of 5" implies the destination is counting down to being abandoned.
This floor retries at the top rung indefinitely; give-up belongs to the
supervisor and already has its own word. The warning now states the rung and the
consecutive-failure count without inventing a countdown.

Full suite 1213 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the encoder

#597 Task 8. The acceptance gate: the first time Tasks 2-7's backpressure meets a
real congested destination rather than a unit test.

`--slow-sink` narrows the link to `--sink-rate` of the configured bitrate with a
bandwidth-limited TCP proxy in front of an RTMP sink, then asserts the whole
property: the stream never fails, the encoder is never rebuilt, Lever A's divisor
steps up and comes back down, buffered latency returns below
kRecoverBelowBufferedMs rather than sitting at the bound, Program holds 60fps, and
the #597 signature - snapshot cadence judged against the core's own advancing
SAMPLE COUNTER (realtimeEvidence.render.completedSlots), never wall time alone,
because that is what separates a producer slowdown from a consumer stall.

Two slow-sink mechanisms were measured and REJECTED, and both rejections are why
the third is trustworthy. SRT transtype=live DROPS what it cannot deliver instead
of blocking (70s: 3800 frames sent, 1984 received, our queue never aged past 0ms,
Lever A never engaged) - a raised latency did not change it. FFmpeg's `-readrate`
throttles MEDIA TIME, not bandwidth, and Lever A reduces bitrate while media time
advances at real time by construction, so the deficit accumulates no matter how
far the divisor climbs - it produced a confident, wrong negative result. A
bandwidth ceiling is what #597 actually was. The throttle is VERIFIED, not
assumed: the run fails unless the outgoing queue really grew past the policy's own
threshold.

Step 3b, the blocking evidence item, is SETTLED: an env-gated, bounded chunk trace
in MediaFoundationGpuVideoEncoder shows every CleanPoint sample is a
self-contained IDR carrying its parameter sets in band - h264 AUD,SPS,PPS,IDR and
hevc AUD,VPS,SPS,PPS,IDR_W_RADL, 200 chunks each, zero non-keyframe chunks
carrying a parameter set. Lever B's discard is safe as written; no
CODECAPI_AVEncVideoPrependSPSPPSToIDR needed.

The gate found a real failure and is left failing on it: 1 of 3 full-length runs
stormed. The outgoing queue crossed 22 -> 60 chunks (its hard cap) inside one
second, the overflow path failed the sender by design and the encoder was rebuilt.
Three properties combine to lose that race - Lever B is gated on divisor > 1 so it
cannot fire for the ~0.5s Lever A needs to step; the 750ms discard threshold sits
only ~15 chunks below the 60-chunk cap; and Lever B's yield is phase-dependent
(measured 2 to 51 chunks freed) because the GOP is the same size as the whole
queue. Full cause chain and recommendations in the task-8 report; the fix is a
design decision, not this task's.

Also found and reported, not fixed: stopFfmpegProcess() waits 500ms for the child
and never terminates, so on a congested link the destination keeps being published
to after Stop and only the job object ends it at core exit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t storm

The slow-sink gate ran the congested case three times; one run stormed with
eleven encoder starts. The chain it measured: the queue crossed its 60-chunk cap
inside a second, and the overflow path fails the sender on purpose so the
supervisor restarts it -- which rebuilds the encoder. That is precisely what the
spec forbids, and it predates this plan.

Ruled: on overflow, discard the GOP tail first and fail only when there is no
keyframe to discard to. That is Lever B's job at the moment it matters most, and
it removes the divisor>1 race by construction. The cap does not move; an
unbounded queue is unbounded latency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
)

The acceptance gate (Task 8) measured backpressure's own last-resort bound
calling the restart storm this sub-project exists to remove: the outgoing
bitstream queue crossed 22 chunks to its 60-chunk cap inside one second, the
overflow path failed the sender ON PURPOSE so the supervisor would restart it,
and the supervisor restart rebuilt the encoder. A destination fault must never
rebuild the encoder.

On overflow, run the GOP-tail discard FIRST and accept the arriving chunk if it
freed room. Fail only when the discard frees nothing - i.e. no keyframe is
queued, the one case where dropping anything corrupts the stream until the next
keyframe. This is Lever B doing the job it was built for at the moment it
matters most, and it removes the divisor > 1 race by construction: the overflow
path does not consult the divisor at all. The 60-chunk / 2 MiB cap is UNCHANGED
- an unbounded queue is unbounded latency.

discardBacklogToNextKeyframe() is split into a ...Locked mutation plus a
locking wrapper: the overflow path already holds bitstreamQueueMutex_, and
std::mutex is not recursive, so calling the locking form there would
self-deadlock the encoder's event loop. Overflow discards are counted in their
own atomic (written on the encoder thread, summed into the published
discardedChunks on the sync thread) and logged at most once a second.

Tests drive the REAL enqueueBitstream through a new offerBitstreamChunkForTest
seam - the existing seam deliberately bypasses the bound: a full queue holding
a keyframe must not fail the sender (it dropped exactly the GOP tail and
accepted the chunk), and a full queue with no keyframe must still fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ach it (#597)

Fix round 1 of Task 8b, driven by the two things round 0 could not claim.

ITEM A - the cut point is a PARAMETER on the pure policy, split by site.
corevideo::core::GopCutPoint::Nearest stays the default, so Lever B's ordinary
discard is byte-identical: its job is recovering latency as the smallest clean
skip. The queue's OVERFLOW path takes ::Last - the most room a safe cut can
free - because its only alternative is failing the sender, which restarts it
and rebuilds the encoder. Both rest on the same safety argument the gate
proved: every keyframe here is a self-contained IDR, so a decoder resumes at
any of them. Nothing inspects a chunk for anything but isKeyframe.

...AND THE ARRIVING CHUNK IS A CUT POINT TOO, which the burst gate measured:
60 queued chunks averaging 9.8 KB - all reference frames, no keyframe anywhere,
so cutting to a QUEUED keyframe freed nothing - while the chunk being refused
was 22.6 KB, keyframe-sized. The queue could not be cut because its own cut
point was at the door. The window is one GOP wide and Lever A stretches the GOP
in wall time (it sheds input frames; the keyframe interval is counted in
frames), so a stalled link fills the queue before the next keyframe arrives.
discardableBacklogForArrival() lets the arrival be the newest keyframe. With no
keyframe queued AND none arriving the sender still fails: the bound is not
optional and the 60-chunk / 2 MiB cap is unchanged.

ITEM B - the gate could not reach the state it asserts about. A sustained
narrowing never reaches the cap (Levers A and B hold the queue at 38-47 for
twelve minutes) and narrowing further is worse: below ~0.5x the link is too
narrow for FFmpeg's own RTMP handshake, so the egress child dies before it
streams and the supervisor restarts a dead destination - a different failure
wearing the same clothes. --burst-sink keeps the gentle sustained narrowing and
STALLS the link outright for 3s every 20s, reproducing the 22-to-60-chunk onset
Task 8 measured, and a burst run now FAILS unless the core logged an overflow
line: a gate that cannot enter its own branch reports success while testing
nothing.

The storm DID recur at the cap before the arrival fix: 3 of 3 burst runs and
1 of 3 SUSTAINED runs, 7-11 encoder builds, every one logging "queue overflow
with nothing safe to drop". Round 0's three sustained greens were three draws
from that same ~1-in-3 rate. After: six runs (3 burst, 3 sustained), all PASS,
exactly one encoder build each, seven overflow-discard events and zero
nothing-safe-to-drop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…failure (#597)

Fix round 2 of Task 8b.

ITEM 0 - the gate could not run HEVC at all, and the bug was not where it
looked. MediaCore::startProgramOutput reads streamOutputProfile.codec into
streamVideoCodec_ and then OVERWRITES every rtmp/rtmps destination's videoCodec
with it, so sending the codec only in destinationSettings left the default
"h264" written over the h265 that was asked for. The SRT branch is not
overwritten, which is why this never showed before --slow-sink moved the gate
onto an RTMP sink. The gate caught its own mismatch ("expected GPU-direct
codec=hevc but the core reported codec=h264") rather than silently passing an
h264 run as an HEVC one. Fixed by sending streamOutputProfile with ONLY the
codec: every other key there defaults to the core's current value, so passing
targetBitrateMbps would have dropped the real stream from 8.2Mbps to 6 and made
the congestion gate easier.

ITEM 1 - the branch-entry assertion was a disjunction, so a nothing-safe-to-drop
line satisfied it: a run where the discard freed nothing and the sender failed
CLEARED the check. That is the third harness here able to report success while
measuring nothing. Burst mode now requires at least one successful
overflow-discard AND zero nothing-safe-to-drop, the latter as its own failure -
it fails the sender, the supervisor restarts it and the encoder is rebuilt,
which is #597 itself.

ITEM 2 - the overflow failure log said "nothing safe to drop (no keyframe
queued)" even when the cut DID drop chunks and only the byte budget was still
over. It now names which case actually happened.

ITEM 3 - discardedChunks counts both discard sites, discardEvents counts only
the policy's decisions (bumping it from the encoder thread would corrupt the
cooldown those counters feed). Documented at the publish site, including that
discardedChunks > 0 with discardEvents == 0 is the hard-cap signature, not a
counter bug.

ITEM 4 - the two characterisation tests are labelled as such, with why no
mutation can fail them and which test carries the killer line instead.

ITEM 5 - the arrival cut point drops the whole backlog even when a narrower cut
would fit: recorded at the site as the latency-first choice it is.

HEVC now runs GPU-direct end to end (mft=hardware-hevc, ffprobe decodes hevc),
but across three congested runs the queue never reached its cap (peaks 0/58/38 -
the cap counts CHUNKS and Lever A limits the arrival rate, so at divisor 4 the
queue reaches seconds of latency in ~23 chunks), so the overflow branch was
never entered on HEVC and the discontinuity question has no observed discard to
judge yet. All three failed the gate correctly; the h264 control run on the same
build passed with 2 overflow-discards and 0 nothing-safe-to-drop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fix round 3. Round 2 tightened the gate to require zero "nothing safe to drop"
lines and, in the same commit, reworded the message the gate greps for. The
substring stopped existing, so the assertion became vacuous by construction and
every reported zero was unmeasured rather than measured-zero. That is the fourth
measuring instrument in this sub-project able to report success while measuring
nothing, and the first introduced while fixing the previous one.

The repair is not a better grep. Both branches of the failure message and the
discard line now come from modules/BitstreamQueueOverflow.h and are pinned by
BitstreamQueueOverflowTest.cpp, so a future rewording breaks a test instead of
silently disarming a gate. The queue's two bounds are named once so the code
that enforces them and the message that describes them cannot disagree.

Also: the counter-split justification was wrong in detail and is corrected -
discardEvents_ is a pure output that feeds no cooldown, and the real reason the
two counters stay separate is the data race on the non-atomic policy object.
The split is now explained on the wire, not only in a C++ comment, so a reader
of the snapshot meets it.

Full suite 1231 passed, 0 failed.

Committed by the controller: the implementing agent completed this work and its
own mutation run (reporting that both assertion halves fired, including the one
that had been vacuous) but hit a session limit before committing. Mutation
reverted and verified absent; build and suite re-run here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…diagnostic technique

CLAUDE.md gains a hazard-map subsection in the GPU-direct streaming section: the
perf.log-gap-vs-sample-COUNTER technique, the buffered-age signal and why not a
frame-count conversion, both levers with their thresholds, the measured (not
reasoned) throttle location, the shed-frame-must-publish-the-handle rule, the two
restart authorities, the overflow path that called the storm, the in-band
parameter-set evidence, the gate command, the four instruments that could have
reported success while measuring nothing, what is NOT proven, and issues #601-#607.

The spec gains an Outcome section recording the Task 1 probe, the Task 7
investigation that disproved its own hypothesis, and the one re-tuned gate
assertion with its reason. The BACKLOG #597 row records slice 1 shipped and names
slice 2.

Documentation only; no production code, test or gate changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#597)

The final whole-branch review's thirteen findings, all taken. Report:
.superpowers/sdd/2026-09-23-stream-backpressure/final-fix-report.md

THE RULING THAT REVERSES AN EARLIER ONE. The restart floor's refusal path
published status="failed" for the whole rung, and this branch lengthened the
rungs from 1-30s to 5-60s. The supervisor classed that Retryable, armed a fault
per rung and gave up after five - so the floor that exists to protect the
encoder became a new path from "congested link" to "stream off until the
operator re-arms", with the exposure window grown two- to sixfold. #603 deferred
it on the grounds that fixing it changes give-up semantics; that reasoning does
not survive the fact that we made the defect materially worse rather than
leaving it alone. isSelfManagedRetryResultCode draws the distinction, and it
takes TWO halves: classify() returning None is not enough, because a destination
serving a 60s rung accepts no units and the "stopped accepting output" branch
arms a fault after 5s by a different door. #603 is CLOSED, not narrowed.

Also:

- BUILD: six GTEST_SKIP() calls in MediaCoreCommandTest.cpp's non-Windows #else
  branches, against a vendored shim that defines no such macro, replaced with
  the house [  SKIPPED ] + return idiom. Verified on the stub configuration
  (COREVIDEO_WITH_RTMP_OUTPUT=OFF, no system GTest), not only on Windows.

- LOG REGRESSION (da21351, missed by its own review): the byte-budget overflow
  line read "[gpu-encode] bitstream the cut freed 60-chunk room but ..." -
  ungrammatical, not findable by grepping "queue overflow", missing dropped, and
  printing the chunk CAP as the cut's yield. Fixed and pinned on the FULL
  composed line; the old assertions passed against the broken sentence.

- CROSS-TASK: only the operator-stop path reconstructed the backpressure policy.
  reopen() now does too, so a recovered destination no longer republishes its
  pre-failure divisor against an empty queue and spend ~30s climbing back.

- OBSERVABILITY: the per-sender node publishes appliedDivisor beside divisor.
  Lever A is per-encoder, so the healthy sibling of a throttled destination was
  reporting divisor 1 while being fed at the maximum.

- All three sender-failing overflow branches now carry one grepped marker, so
  validate-gpu-encode.mjs's attribution column counts what its comment claims.

- Docs: spec 5 amended (what shipped is removal of the overflow trigger plus a
  rate bound, not "never rebuild the encoder"); spec 7 amended (the operator
  readout was never built - #610 - and shedFrames is deliberately global); the
  divisor ladder stated as programFps / divisor in all three places, since at a
  30 fps program the floor is 7.5 fps and not 15; the HEVC discard evidence
  stated precisely as decodability, not A/V sync; IOutputSender's five test-only
  virtuals' rule corrected to match reality with a hard "no sixth" (#611);
  discardCounterNote de-allocated; lastReason's shared-between-levers caveat
  written down; one stray blank line.

Windows dev suite 1236/0 (baseline 1231/0), Release core 2,349,568 bytes.
Stub gate green. No live congested gate run in this wave.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Final re-review, still-open item 1. The operator stop and the supervisor restart
both reset the backpressure policy; the adapter's own re-open at the end of a
floor rung did not. A self-reopened transport therefore resumed at its
pre-failure divisor against an empty queue and needed about thirty seconds of
health to climb back. Quality only, never stream-off -- but a reopen is a new run
by every other measure this adapter keeps, so the policy resets with them.

Also: the CLAUDE.md note on #603 pointed "below" at a section that is above it.

Full suite 1236 passed, 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@iamfatness
iamfatness merged commit 6f4f025 into main Sep 24, 2026
19 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.

A long restart-floor backoff can walk the supervisor to give-up H.264 10 Mbps YouTube stream coincides with ~20-second operator stall

2 participants