Skip to content

fix(frontend): show units and a generic heading in the block statistics panel - #882

Merged
srperens merged 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/stats-panel-units
Sep 29, 2026
Merged

srperens merged 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/stats-panel-units

Conversation

@wagenet

@wagenet wagenet commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Opened by Claude (Claude Code) on behalf of @wagenet.

The statistics panel from #857 ignored StatMetadata.unit, so a configured buffer of 400 ms read as 400 and packet counters as bare numbers. Its heading and empty-state hints also said "RTP", but any block that reports statistics can use the panel. The Audio Bridge Input in #844 is one such block. This PR does not depend on #844.

  • StatValue::format_with_unit and Statistic::format_value in strom-types append the unit. DurationNs keeps the unit it picks itself, Bool stays Yes/No, and an empty unit ("") counts as none. No producer on this tree sends one, but the Audio Bridge helper in feat(audio): adaptive audio bridge for the conversation return #844 does for its Bool stats.
  • The panel uses format_value. The heading is now "📊 Statistics", and the hints read "Statistics appear once the block receives a stream." and "Start the flow to see this block's statistics."

Tests

  • New strom-types unit tests in stats::tests: 5 ran and passed. I reverted each branch of the formatter in turn: ignoring the unit, appending it to DurationNs, appending it to Bool, and treating "" as a unit. Each revert made at least one test fail.
  • cargo test --lib stats:: (backend stats tests): 9 passed. No other test suites were run locally.
  • cargo fmt --check, cargo clippy --all-targets -D warnings, the WASM clippy (--target wasm32-unknown-unknown, as in CI) and the trunk WASM build all passed.

Visual check

I ran a local backend with a WHIP Input flow (audio only) and a headless whipclientsink publisher, then viewed the web UI in Chrome. The panel showed 8289 packets, 2.82 ms average jitter (no doubled unit), 400 ms configured buffer and 456 requests. Both empty-state hints appeared as intended: one with the flow running and no publisher, one with the flow stopped. I did not check an AES67 input visually. It produces the same statistics through the same RtpJitterbufferStats::to_statistics.

🤖 Generated with Claude Code

…cs panel

The panel rendered values with StatValue::format(), which ignores
StatMetadata.unit, so 40 ms read as "40.00" and packet counters as bare
numbers. Add StatValue::format_with_unit and Statistic::format_value in
strom-types and use them in the panel. DurationNs keeps the unit it
picks itself and Bool stays Yes/No; an empty unit counts as none.

The heading and empty-state hints said RTP, but the panel serves any
block that reports statistics. Make them generic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wagenet
wagenet marked this pull request as ready for review September 26, 2026 14:30
@srperens srperens mentioned this pull request Sep 28, 2026

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes. The formatter and the panel change are both correct, and all five new tests ran in CI. What blocks is one comment in the tests. It says the current /rtp-stats endpoint does something it does not do on this tree. Correcting that comment is the whole request; the rest of the review would approve.

Requested changes

  1. Fix the provenance comment at types/src/stats.rs:348 — // /rtp-stats sends Bool statistics with an empty unit, not null. On this branch and on main, the only producer of Statistics is RtpJitterbufferStats::to_statistics, and it emits no Bool at all. Its units are all non-empty, e.g. types/src/stats.rs:180 — unit: Some("packets".to_string()),. The empty-unit Bool stats come from #844's Audio Bridge helper, which does unit: Some(unit.to_string()) for producer_attached/producer_overrun. Say that, or state the rule generically: "an empty unit counts as none". The same sentence in the PR body needs the same fix. The handling itself is right, so keep it.

Claims

Claim Verdict Evidence
The panel now shows units for every statistic it lists CONFIRMED Both render sites switch to the new method: frontend/src/properties.rs:1279 — let formatted = stat.format_value(); and frontend/src/properties.rs:1306 — let formatted = stat.format_value();. A grep finds no other StatValue::format() caller in frontend/src
DurationNs and Bool never get a doubled or odd unit CONFIRMED The first match arm of format_with_unit returns the bare value for both variants (quoted below). avg_jitter_ns carries unit: "ns", so without that arm it would read "1.50 ms ns"
/rtp-stats sends Bool statistics with an empty unit CONTRADICTED See requested change 1. This is true only once #844 lands
The five new tests guard the formatter CONFIRMED Check (Linux) job 108422252909 at b373d30: stats::tests::{numeric_values_show_their_unit, duration_keeps_its_own_unit, bool_takes_no_unit, missing_or_empty_unit_shows_bare_value, jitterbuffer_stats_read_with_units} ... ok. They call Statistic::format_value itself, so reverting any arm fails at least one of them

The arm: types/src/stats.rs:66 — (StatValue::DurationNs(_) | StatValue::Bool(_), _) => value,

Radius — LOCAL. The change is display only. strom-types gains two methods and no fields, so the serialized shape and the OpenAPI schema are unchanged, and API Contract Check is green. The heading and hint text change for every block that shows the panel, which on this tree means AES67 Input and WHIP Input.

Overlaps — #844 is the other producer this PR anticipates. It extends RTP_STATS_BLOCK_DEFINITION_IDS and gains units from this PR, but neither PR needs the other. #809 edits frontend/src/properties.rs about 60 lines below these hunks, so a rebase may conflict, but the overlap is textual only. Neither settles a design question.

Tests & CI — Run 36248581300 at b373d30: Check (Linux), both Linux builds, WASM, API Contract Check and Static JS Tests all green. macOS and Windows were skipped, and nothing here is platform-specific. The UI change is covered only by the author's visual check. That is fine for strings and a method swap.

frontend/src/properties.rs is 2961 lines, far past CLAUDE.md's 1500-line mark. The stats panel would make a clean first sub-module, but not in this PR.

Confidence: HIGH

/rtp-stats emits no Bool statistics on this tree; the empty-unit Bool
stats come from the Audio Bridge helper in Eyevinn#844.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet

wagenet commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Posted by Claude (Claude Code) on behalf of @wagenet.

Fixed in 2d60c59. The test comment now states the rule generically ("An empty unit counts as none"). The PR body now says no producer on this tree sends an empty unit, and that #844's Audio Bridge helper does for its Bool stats. No code change. The five stats::tests tests pass locally.

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supersedes my Request changes at b373d30. The verdict moves to Approve, because the one requested change is made.

Verdict: Approve. At 2d60c59 the only change since my last review is one comment line. compare b373d30...2d60c59 shows ahead_by=1, behind_by=0 and one file. The PR body has the same fix.

Claims

Claim Verdict Evidence
The provenance comment no longer credits /rtp-stats with empty-unit Bool stats CONFIRMED types/src/stats.rs:348 — // An empty unit counts as none. The PR body now says "No producer on this tree sends one, but the Audio Bridge helper in #844 does"
Panel units, the DurationNs/Bool arm, and the five tests guarding the formatter CONFIRMED Confirmed in my review of 2026-09-28 at b373d30. None of those lines moved

The arm is unchanged: types/src/stats.rs:66 — (StatValue::DurationNs(_) | StatValue::Bool(_), _) => value,

Tests & CI: I checked run 36486749200 at 2d60c59. Check (Linux), both Linux builds, WASM, API Contract Check and Static JS Tests are green. The Check (Linux) job log (109145432918) shows all five stats::tests::* tests ... ok. macOS and Windows were skipped, and nothing in this diff is platform-specific.

Radius: LOCAL, carried from my previous review. Overlaps: #844 and #809 are unchanged from my previous review. Both are textual or anticipatory, and neither settles a design question.

Confidence: HIGH

@srperens
srperens merged commit e5a48fe into Eyevinn:main Sep 29, 2026
9 checks passed
srperens added a commit that referenced this pull request Sep 29, 2026
Add #731, #882 and #889, which landed on main after the bump was opened.

Audit every section from 0.4.10 on against the commits between its tags
and add the PRs that were never written up: #505-#507 and #514 in 0.4.11,
#533, #541 and #550 in 0.5.0, #577, #580, #581 and #584 in 0.6.0, #638 in
0.6.5 and #829 in 0.6.9. Only version-bump PRs are left unlisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Sep 29, 2026
Add #731, #882 and #889, which landed on main after the bump was opened.

Audit every section from 0.4.10 on against the commits between its tags
and add the PRs that were never written up: #505-#507 and #514 in 0.4.11,
#533, #541 and #550 in 0.5.0, #577, #580, #581 and #584 in 0.6.0, #638 in
0.6.5 and #829 in 0.6.9. Only version-bump PRs are left unlisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Sep 29, 2026
* chore(release): bump version to 0.6.11

Bump the workspace version to 0.6.11 (Cargo.toml, Cargo.lock, the OpenAPI
info.version in openapi.json) and add the 0.6.11 changelog entry for the
27 commits since 0.6.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): add the three PRs since, and fill gaps in 0.4.11-0.6.9

Add #731, #882 and #889, which landed on main after the bump was opened.

Audit every section from 0.4.10 on against the commits between its tags
and add the PRs that were never written up: #505-#507 and #514 in 0.4.11,
#533, #541 and #550 in 0.5.0, #577, #580, #581 and #584 in 0.6.0, #638 in
0.6.5 and #829 in 0.6.9. Only version-bump PRs are left unlisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): add #876 to 0.6.11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Oct 2, 2026
* fix(whip): null a session bin whipserversrc removes mid-teardown

whipserversrc ends a WHIP session by taking the session's internal bin to
NULL and removing it from itself, on its own thread. Nothing waits for
that: the guard its unprepare() blocks on is cleared before the task
touches the bin. When Strom tears the session pipeline down at the same
moment -- both are woken by the same ice-connection-state emission -- the
pipeline's PLAYING->PAUSED step raises the already-NULL bin back up (a
GstBin is never skipped for being below the target state), and the
removal lands while it is PAUSED:

    GStreamer-CRITICAL: Trying to dispose element bin31, but it is in
    PAUSED instead of the NULL state.

The bin and everything under it -- webrtcbin, rtpbin, its UDP sockets and
threads -- is then dropped without ever running READY->NULL.

Catch the element on the way out instead: element-removed fires with no
bin lock held and the child already unparented. Lock its state, then set
it to NULL, so whichever thread reaches the child's state lock second
sees the other's effect.

Also wait out an ASYNC return in teardown_session_pipeline. Nothing in a
session pipeline goes async on the way down today, and it was not the
cause here, but dropping a pipeline mid-transition would leave the same
kind of orphan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(whip): drop the debug trace from the orphan_guard module doc

The two paragraphs below it already state the rule the trace illustrates:
a plain child at NULL is skipped by a downward transition, a GstBin is
recursed into unconditionally, and the bin leaves the pipeline before the
descent can reach it again. The interleaving of one debug run is the
argument for that rule, which belongs in review, not in the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(release): bump version to 0.6.9 and document 0.6.6-0.6.9 (#828)

Bump the workspace version to 0.6.9 (Cargo.toml, Cargo.lock, the OpenAPI
info.version in openapi.json) and write the changelog entries that were
never added for 0.6.6, 0.6.7 and 0.6.8, plus the new 0.6.9 entry.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: catch up with 0.6.9 — RTMP output, and the MCP server that is gone (#830)

The standalone stdio MCP server was removed in #765, which updated MCP.md,
INTEGRATION.md, FAQ.md and DOCKER.md's "MCP in Docker" section but left two
references behind: DOCKER.md still described the backend builder stage as
building "the backend and the MCP server", and FEATURE_SUGGESTIONS.md still
built an idea on top of "the existing MCP server". The Dockerfile has no such
stage. The README now says outright that MCP is served by the backend itself,
so a reader does not go looking for a binary that no longer ships.

The RTMP Output block landed in 0.6.9 (#827), so the README's protocol and
block lists name RTMP, and the "publish to the big live platforms" idea no
longer describes the transport side as still open — what is left there is the
operator write-up and the encoder profile question. TAMS Output shipped back
in 0.6.6 and was never listed either.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(config): map every STROM_* env var explicitly (#831)

The figment env provider split variable names on every underscore, so it
could only ever express a single-word field name. `server.port` worked;
`STROM_SERVER_ICE_TRANSPORT_POLICY` became `server.ice.transport.policy`,
matched no field, and was silently dropped. `ice_servers`, `tls_cert` and
`tls_key` had already been special-cased for exactly this reason, but the
rest of the documented variables were no-ops.

Replace the generic provider with an explicit table of variable to config
key. Scalars and comma-separated lists are handled separately, so the
list keys (`ice_servers`, `cors_allowed_origins`,
`sap_multicast_addresses`) no longer need one-off blocks.

Now working, previously ignored:
- STROM_SERVER_ICE_TRANSPORT_POLICY
- STROM_LOGGING_LOG_FILE, STROM_LOGGING_LOG_LEVEL
- STROM_STORAGE_DATABASE_URL, STROM_STORAGE_DATA_DIR,
  STROM_STORAGE_FLOWS_PATH, STROM_STORAGE_BLOCKS_PATH,
  STROM_STORAGE_MEDIA_PATH, STROM_STORAGE_CEF_CACHE_PATH
- STROM_SERVER_CORS_ALLOWED_ORIGINS
- STROM_DISCOVERY_SAP_MULTICAST_ADDRESSES

The storage keys had working clap aliases (STROM_DATA_DIR and friends),
so only the documentation was wrong there. The logging keys had no
working path at all: log file and log level could only be set from TOML.

An invalid STROM_SERVER_PORT now reports what is wrong with it instead of
failing inside figment's extraction.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(whip/whep): expose a per-block ICE transport policy (#832)

* feat(whip/whep): expose a per-block ICE transport policy

The ICE transport policy was server-wide only: forcing TURN relay for one
endpoint behind a network that host and server-reflexive candidates cannot
cross pushed every other WebRTC block onto relay as well.

WHIP Input, WHIP Output, WHEP Input and WHEP Output now each expose an
`ice_transport_policy` property. It defaults to the empty value, which
inherits `server.ice_transport_policy`, so no existing flow changes how it
negotiates; "relay" forces that block's candidates through the configured
TURN server, and "all" opens one block up on a relay-only server.

Resolution lives on `BlockBuildContext` so all six builder paths share it,
and a value that is neither "all" nor "relay" falls back to the server
setting — the value reaches webrtcbin via `set_property_from_str`, which
panics on an unknown nick, and properties arrive over the API.

The stable `whepsrc` path never applied the policy at all; it does now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(whip/whep): set the ICE policy on the element that owns the webrtcbin

whipsink, whepsrc and the webrtcsink-based whipclientsink/whepserversink
expose `ice-transport-policy` themselves and hand it to every webrtcbin they
create, as they create it. Setting it there is more robust than reaching the
child from a `deep-element-added` handler, which depends on the child's name
and on running before that session's pipeline leaves READY — the last state
in which webrtcbin accepts the property.

The child-level handlers stay: they cover an element version without the
property, and they carry unrelated setup (opusenc, stats registration,
fakesinks on unlinked TransportReceiveBin pads) that has to stay anyway.
whipserversrc and whepclientsrc have no such property, so those two blocks
still rely on the handler alone.

Reading the value back off the built element makes five of the six paths
testable without a negotiated session, so each one now has a guard that fails
if the block stops carrying its own policy.

Also warn when a resolved policy of "relay" meets an ice_servers list with no
TURN server: the block then gathers no candidates at all and never connects,
and nothing else reports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(agent): make triage and review look for what is already open (#841)

A LOCAL diff can settle a GLOBAL design question by being merged first, and
nothing in the protocol asked either stage to look. Issue #837 was triaged on
its own merits — two options, both inside one block, radius SHARED, recommend
the smaller — while citing #682, the open issue that owns where GL memory gets
downloaded, and without mentioning open PR #802, which changes the very linker
that block's internal links go through.

PROTOCOL.md gains a mandatory search for open issues and PRs that own the same
design decision or change the same code path, and an overlaps= marker field.
TRIAGE.md requires the proposal to name what it found, and to offer "wait for
#N, change nothing here" as an option a human can choose — pausing is a
legitimate outcome and not the agent's to decide. REVIEW.md gains the same
check, and may not approve a PR that settles an open design question on the
project's behalf. board.sh reports a non-none overlaps= against the candidate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gst): download GL memory where a consumer cannot take it (#802)

* fix(gst): download GL memory where a consumer cannot take it

A consumer whose external video input is a videoconvert or videoscale with an
encoder behind it answers a caps query with system memory only, so a
video/x-raw(memory:GLMemory) producer shares no format with it and the link is
refused before any caps are negotiated. Nothing retries a link that failed for
want of a common format, so a Vision Mixer on the GPU backend with gl_download
off leaves the Video Encoder and everything below it with no data while the
flow reports Playing.

Fronting the consumer with a queue or an identity does not open the link: both
answer a caps query by proxying it downstream. Adapt instead at the one point
where both pads are known. When a plain link fails and the producer offers GL
memory and nothing else while the consumer wants raw video in system memory,
link them through a gldownload. A path that links on its own never reaches this
code, so no flow pays for a download it did not need, and CUDA, NVMM, D3D11 and
VA producers are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gst): adapt only a format refusal, and undo a splice that fails

Run the GL download fallback only for a PadLinkError::Noformat refusal. An
already-linked consumer passes the caps test just as a system-memory one does,
and a gldownload cannot link to it either.

Remove the gldownload again if either half of the splice is refused, so the
producer is left unlinked rather than feeding an element with no output.

Require the consumer to take system memory, which is what a gldownload
produces, rather than any memory other than GL: a CUDA- or D3D11-only consumer
cannot be fed through one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gst): adapt GL-memory links that leave a pad unnamed

The linker adapted only links that named both pads. A link that names one
pad or none is made element to element, and GStreamer refuses it without
saying why, so a GL producer linked that way still failed.

On such a refusal, pair the named or unlinked pads on each side, link the
first pair a gldownload would join to get the concrete refusal, and send it
through the pad-level path.

Also declare gstreamer1.0-gl in CI, which the tests need for gltestsrc and
gldownload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(devicesrc): let the capture front negotiate GL memory (#842)

A capsfilter naming only `video/x-raw` means `memory:SystemMemory`, not
"any". A camera that delivers GL memory and nothing else — an AVFoundation
camera behind avfvideosrc — shares no format with the capture front and
cannot negotiate it at all.

The front now offers both memory types, system memory first so a device that
can deliver either keeps producing what it does today. No download is asked
for here: where the camera picks GL memory, the linker splices the gldownload
in front of the block's system-memory output.

Reported in #837.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* tools: add a probe for how a host bridges GPU video memory (#843)

* tools: add a probe for how a host bridges GPU video memory

Phase 1 of #682 needs the same table from every platform and GPU vendor we
support: which memory types a host can produce, what autovideoconvert selects
for each, and whether a GPU-to-GPU path stays on the GPU. Answering that by
hand produces four differently shaped answers.

One script, the same command everywhere. It detects what the host can produce
and probes only that, so a machine with no NVIDIA card reports the GL rows and
says the CUDA ones were skipped rather than failing. Every row is a real
pipeline: OK means buffers flowed.

It needs GStreamer on PATH and nothing else — no build of Strom, and no GPU
for the parts that do not use one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* tools: print the selected converter bin on a failure too

The sub-bin autovideoconvert picks is the answer the probe exists for, and a
chain that fails still says what was selected — on a headless NVIDIA host it
reaches for GL and dies on EGL, which is the finding, not the noise. The error
line is no longer truncated either: the reason is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* tools: report the converter selection that a grep was missing

The Windows run came back with every D3D11 row blank, which read as "nothing
was selected" and was the opposite of what happened: autovideoconvert had
chosen d3d11convert. Match the element inside the bin as well as the bin name,
so a selection is reported whichever shape the log takes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docker): pin the patched decklink plugin by digest (#836)

The runtime stage fetched libgstdecklink.so from a GitHub release by tag name and wrote it straight into the GStreamer plugin directory. Release assets are mutable, so a replaced asset would be installed into a system library path and loaded into the strom process with nothing in the build failing.

Each artifact is now pinned to its SHA-256 and verified before it is installed. The download lands in /tmp, sha256sum -c gates it, and only then does install(1) place it in the plugin directory. An architecture with no pinned digest fails the build with a clear message.

Verified locally at head 87e0a0e: both architectures install the pinned digest, a wrong digest and an unpinned architecture both fail the build before install is reached.

* fix(videoenc): reject out-of-range client properties instead of panicking (#821)

Blocks applied client properties with the raw GStreamer setters that #724 stopped
using: the checked conversion it added was private to a private module and
unreachable from a block. x264enc's bitrate is a guint of 1..=2048000, so a
bitrate of 0 aborted the thread whenever g_param_value_validate had to clamp,
unwinding across the axum handler — the connection dropped and start_flow's error
path never ran, leaking the flow.

Exposes the checked conversion as pub(crate) set_property_checked and routes all
32 text-form setters in set_encoder_properties through it.

backend/tests/block_property_range_test.rs drives the real VideoEncBuilder. The
test alone is red in CI and green with the fix, so it guards the fix rather than
demonstrating it.

Refs #769 — 31 set_property_from_str sites remain under backend/src/blocks/, so
the issue stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(windows): fail the job on a failing first command, and install every plugin (#838)

A run: step on a Windows runner defaults to pwsh, where a native command exiting
non-zero neither aborts the script nor fails the step — only the last command's
exit code counts. A failing cargo test followed by a passing one read as green for
as long as this job has existed. The job now takes the shell: bash default every
other job in the workflow already has.

Install GStreamer cannot take that default (it needs Invoke-WebRequest and
Start-Process), so it checks exit codes by hand instead: both msiexec calls go
through a helper using -PassThru that throws on a non-zero ExitCode, and choco
checks LASTEXITCODE. Start-Process without -PassThru discarded the code entirely,
so a failed install surfaced twenty minutes later as a missing element and looked
like a broken test.

ADDLOCAL=ALL installs every plugin group rather than the MSI's default feature
set, which left out x264enc among others and made the recorder tests skip green on
this runner alone. With the job able to report a failure and every plugin present,
STROM_REQUIRE_GST_PLUGINS is enabled here.

Three Windows exclusions, each naming a tracked defect rather than working around
one: the mpegtssrt streamheader test (#834), the recorder pipeline-start EOS test
(#835, left as ignore so it stays visible in the run), and the rtmp package-hint
assertion, where one MSI legitimately carries both packages — asserted as an
equality on Windows rather than dropped, so a future split fails loudly.

Two test guards that could not fail were closed in the same pass: recorder
container_available now shares the STROM_REQUIRE_GST_PLUGINS assert instead of
skipping silently on a missing muxer.

Verified: Build (Windows) green at a25ec9e, 26 minutes. The install step ran the
new exit-code handling, both modified tests executed on Windows, and no container
was skipped.

Closes #702
Closes #703

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(monitor): sample per-thread CPU on macOS via mach thread_info (#722)

Per-thread CPU sampling read /proc behind cfg(target_os = "linux"), so the
thread and CPU views were empty on macOS. Adds a mach thread_info backend
reporting the same quantity in the same units, sharing cpu_usage_percent()
with the Linux path, which is unchanged.

Also fixes an EXC_GUARD crash: the registry stored a mach port name taken
with pthread_mach_thread_np(), which takes no user reference. Once the
thread exited the name was freed and could be recycled to a guarded port,
and thread_info() on it killed the process. ThreadHandle now owns a send
right and releases it on drop.

Verified on this repository's CI at c0466de3: Linux, WASM and the API
contract check green, and Build (macOS) green via the ci:macos label with
clippy -D warnings and the full test suite under STROM_REQUIRE_GST_PLUGINS
and STROM_REQUIRE_GL.

* fix(vision mixer): keep per-pixel alpha on keyed pads with an alpha-less output_format (#748)

Setting an alpha-less output_format (I420, NV12, RGB, ...) on the CPU path
destroyed the per-pixel alpha of every keyed pad: a DSK graphic's transparent
areas composited as the graphic's own black, and the full-canvas multiview
overlay turned black.

compositor blends in its own output format and converts every sink pad to it
first, so pinning an alpha-less format on the compositor's src caps makes its
sink pads alpha-less too. When a compositor has keyed pads and output_format
cannot carry alpha, blend in the alpha-carrying counterpart of the requested
format and convert to output_format after the mixer. output_format still pins
what leaves the block; it no longer dictates the blend space.

The GPU path is unchanged: glvideomixer is a bin whose internal converter
absorbs the pin, so the blend stays RGBA in GL memory. That invariant is now a
comment there — keep a converting element between a mixer and any format pin.

Cost: both compositor outputs gain a full-frame conversion per frame. The blend
space matches the requested chroma subsampling, so the added hop is an alpha
strip, not a resample. Only flows with a keyed pad and a non-Auto output_format
pay it.

New vision_mixer_keyed_alpha_test.rs builds a real flow per branch of
alpha_blend_format, so a format that loses its key is named by the failure.
Reverting the blend substitution turns all four red and leaves Auto green.

* chore(release): bump version to 0.6.10 (#849)

Bump the workspace version to 0.6.10 (Cargo.toml, Cargo.lock, the OpenAPI
info.version in openapi.json) and add the 0.6.10 changelog entry for the
twelve commits since 0.6.9.

Cut for #837: a GLMemory-only avfvideosrc stalled macOS camera capture to
~1 fps, fixed by #802 and #842, both of which landed after 0.6.9 was tagged.
Every CI artifact on main carries the 0.6.9 version string, so a post-release
build could not be told apart from the release that lacks the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(vision_mixer): reject a PiP zone whose sources exceed its capacity (#808)

* fix(vision_mixer): reject a PiP zone whose sources exceed its capacity

`Zone::effective_sources` truncates from the front at render time, keeping
only the newest `capacity` entries. The `/pip` validator copied `sources`
through verbatim, so a zone with capacity 2 and sources [0, 1, 2] returned
200, stored all three, and rendered two. A later GET reported a composition
that had never been on air, and the discrepancy was permanent.

Breaking: an over-capacity PUT returns 400. A caller that relied on
server-side truncation must evict down to capacity before sending.

Which sources are on screen is semantic state, not layout, so this now
fails with 400 alongside the other semantic checks in that validator —
matching the rule the border-colour check already states: draw nothing and
tell nobody is not an acceptable outcome. Evicting the oldest stays the
client's job (FIFO); the built-in editor already did it at every mutation
point, and now also repairs state adopted from another client before it
sends, so a poisoned composition cannot wedge the panel.

Also moves the MAX_PIP_OVERLAYS ceiling out of the HTTP handler into the
validator. It counted stored sources, which could exceed the rendered
count, and it ran before the per-source checks, so a config with a source
repeated across zones reported "too many sources" instead of naming the
duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(vision_mixer): guard the PiP overlay ceiling and its ordering

The MAX_PIP_OVERLAYS check moved into the validator with no test of its
own. Add one on a full-size CPU mixer: the ceiling rejects one source
too many, and a config over the ceiling only because of a duplicate
reports the duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stats): report WHIP Input jitterbuffer stats per seat (#793)

* feat(stats): report WHIP Input jitterbuffer stats per seat

/api/flows/{id}/rtp-stats covered AES67 Input only, so a WHIP endpoint
returned an empty block list. A WHIP publisher's receive path is not in
the flow's pipeline: each session runs its own, bridged over
appsink/appsrc. The collector now asks the session manager for those
pipelines and reports one set of jitterbuffer statistics per occupied
slot.

Stats are keyed slot<n>_<medium>_, so a seat's audio and its video can
be told apart. The medium cannot be read off the jitterbuffer's own
pads, where webrtcbin has stripped the RTP caps to an SSRC, so it is
resolved by following the stream downstream to the first caps that name
one.

RtpJitterbufferStats also carries the buffer's configured size. Measured
jitter is only actionable next to the buffer it is measured against, and
AES67 reports it too now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(stats): make the media-kind walk test cross element boundaries

The test read audio caps off the source's own pad, so it returned on the
first iteration and never followed a peer. The element under test now
carries webrtcbin-style caps naming only an SSRC, and the medium appears
two elements downstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(liveaudiorouter): give an output bus headroom and a ceiling (#795)

* feat(liveaudiorouter): give an output bus headroom and a ceiling

An output bus sums every crosspoint routed to it with nothing to catch the
result. On a mix-minus rig that is N-1 talkers at unity into one bus, and
those buses are commentators' headphone feeds.

Measured on the five-seat rig with five WHIP publishers sending real speech
at browser-AGC level: all five returns pegged at full scale, clipping in 40
to 45 of every 48 seconds. Offline on the same speech, four seats aligned to leave
16 dB of peak headroom each still sum to a median +2.3 dBFS.

Two stages per output, both no-ops by default:

  output_headroom       attenuates the bus, 0 to -24 dB
  output_limiter_enabled  rglimiter, which cannot emit full scale

The trim is the fix and the limiter is the backstop, in that order. On the
rig, -6 dB of trim takes every return to zero clipped samples; the limiter
alone only cuts clipping to 4-18 seconds in 48, because Opus overshoots a
signal that sits at exactly 0 dBFS. What the limiter covers is a trim that
was set for fewer seats than turned up.

Not make_limiter_element. lsp-rs-limiter rides gain on an envelope and its
attack loses the speech transient: on a four-seat return 5 dB over it still
passed 0.05% of samples above full scale at a -3 dB threshold, and 0.0007%
at -10 dB, where it also costs 7.5 dB of level. rglimiter is a waveshaper
with an asymptote at full scale and reaches 0.0000%. Neither adds latency:
an impulse through either shows no sample delay and the pipeline latency
query is unchanged.

caps_out_O pins F32LE once either stage is engaged. audiomixer saturates at
the sum in a fixed-point format, so with the format left open whether a
fan-in overload clipped inside the router or reached the output intact
depended on what the next block negotiated, and neither stage can act on a
sum that is already gone.

A router that asks for neither is left alone down to its negotiation: no
limiter element in the chain, the trim at unity, and the bus format
unpinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(liveaudiorouter): stop the fan-in tests assuming four tones arrive in phase

Each audiotestsrc starts its sine at its own first buffer, so four copies of
one tone summed at whatever relative phase the machine handed out: coherent
at +6 dBFS here, 3.5 dBFS on a Linux runner, which failed the bound.

Four spread frequencies instead. Their relative phase sweeps through
alignment hundreds of times a second, so the loudest moment in the window
lands on the coherent sum — a stable +6.0 dBFS over repeated local runs —
and the bound now sits 4 dB clear of it.

The trim test no longer asserts an absolute level at all. It measures the
same router with and without the trim and asserts the difference, so it
tests what the trim does rather than what four live sources happened to
sum to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(liveaudiorouter): let the fan-in bus wait for all four sources

An output bus emits after `latency` whether or not every pad has delivered,
and `make_audiomixer` also sets `ignore-inactive-pads`. At the 30 ms default
a starved source misses the deadline and the bus sums three of the four, so
the level asserted becomes a property of the runner.

Raise it to 500 ms in the fan-in rig only. Twelve runs of the whole binary
at --test-threads=8 against ten spinning cores pass with this and the spread
tones in place; the version before both failed on two Linux runners and
under the same local load. The two were not separated, so this stands on the
aggregator's documented behaviour rather than on an isolated measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(liveaudiorouter): name the output stages the way a console does

"Trim" is input gain at the top of a channel strip, not a bus output level,
and "headroom" is the margin left below full scale, so `output_headroom = -6`
read backwards. The control is a bus master fader: `output_fader_db`, label
"Output Fader (dB)", matching the mixer block's `aux{N}_fader` and the
`fader_db` example in the API docs.

`rglimiter` has no attack or release. It is a fixed per-sample curve, which
is a soft clipper, and it behaves differently from the mixer block's Main
Limiter. The property becomes `output_soft_clip_enabled`, label "Output Soft
Clipper", and its description says it distorts rather than turns down when
driven hard.

Element ids, constants, `fader_db_for_sources` and test names follow. The
module comment no longer claims the fader-then-clipper order is a console
convention; it gives the measured reason. The fader comment now says `volume`
narrows the bus to signed and float formats instead of claiming it passes
any raw format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(liveaudiorouter): guard the float pin for a fader-only bus

Every fan-in test fed F32LE sources, and the only running pin check enabled
the soft clipper, whose F32LE-only caps force float on their own. Nothing
failed if `sum_in_float` lost its `|| fader_db != 0.0` half.

The new test engages the fader alone between S16 sources and an
audioconvert-then-S16 consumer. It asserts the audible consequence first:
the faded sum arrives near -6 dBFS, where a sum saturated inside audiomixer
would arrive at -12 dBFS. Then it asserts mixer_0 negotiated F32LE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(liveaudiorouter): check an unconfigured bus negotiates at runtime

The existing default-router test inspects the build output: no clipper
element, no format field on caps_out. It never runs the bus. The new test
feeds S16 sources into an S16-only consumer with no converter and requires
audio to arrive. The router's output carries its sources' format, so a bus
pinned to float or holding an F32LE-only rglimiter refuses that consumer.

The fader comment claimed its missing unsigned formats were a change to an
unconfigured bus. They are not: every crosspoint is a `volume` too, so
unsigned audio never reaches the mixer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(liveaudiorouter): suggest a fader for two voices, not N-1

`fader_db_for_sources(n)` sized the fader for every other seat talking at
once: -6 dB for four. Conversation does not do that. Simultaneous speech is
3.8% of two-party phone calls (Levinson & Torreira 2015) and 11.6% of
speaking time in 3-9 person meetings, where over 90% of overlapped time has
a single other talker (Cetin & Shriberg 2006). A two-person rehearsal on the
rig overlapped for 3.3% of speaking time, with a 200 ms median.

`SUGGESTED_OUTPUT_FADER_DB` is -3 dB with the soft clipper on, whatever the
seat count. Two voices at WebRTC's default AGC peak target of -3 dBFS reach
+3 dBFS where their peaks meet; -3 dB puts that at full scale and leaves the
clipper the rest. The helper's only caller was the property description,
which now gives this value and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(frontend): show RTP statistics for any block that reports them (#857)

Poll rtp-stats for running flows containing any block type listed in
RTP_STATS_BLOCK_DEFINITION_IDS (AES67 Input and WHIP Input), and draw the
RTP Statistics panel for any block that has an entry in the response
instead of only for AES67 Input. Block types in the list that have no
stats yet get a hint; a running flow without a jitterbuffer yet now says
stats appear once a stream is received instead of asking to start the flow.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(videoenc): default to the codec's 8-bit 4:2:0 profile (#850)

The block pinned no profile by default, so x264enc followed whatever
pixel format reached it. From a 4:2:2, 4:4:4 or 10-bit input that
produced a profile most delivery targets refuse, with nothing logged.

This is not latent. The vision mixer pins format=RGBA on its output on
both the CPU and GPU paths, and videoconvert scores RGBA to Y444 as less
lossy than RGBA to I420, so an ordinary mixer to encoder flow already
encoded High 4:4:4 Predictive. Measured on GStreamer 1.28.1:

  input       unpinned                     profile pinned
  I420        High / yuv420p               High / yuv420p
  RGBA        High 4:4:4 Predictive        High / yuv420p
  UYVY        High 4:4:4 Predictive        High / yuv420p
  Y42B        High 4:2:2                   High / yuv420p
  I420_10LE   High 10                      High / yuv420p
  P010_10LE   High 4:4:4 Predictive        High / yuv420p

H.265 has the same escalation: RGBA gave Rext / yuv444p unpinned.

Add Profile::Auto and make it the default. It resolves per codec rather
than to a single name, because "high" is not an H.265 profile: pinning
it on an h265 capsfilter fails to negotiate outright, so a codec-blind
default would have traded a bad stream for a dead pipeline. H.264 gets
high, H.265 gets main, AV1 and VP9 are unchanged and carry no profile
field. profile=none stays available for free negotiation.

Nothing pins the raw pixel format. Only the profile field on the
existing capsfilter changes, and it reaches the encoder by propagating
back up through the parser. A capsfilter on the converter's output would
force a GPU readback on the memory paths that avoid one.

* ci: stop Static JS Tests re-running on every label event (#858)

Every job in the workflow either carries `if: github.event.action != 'labeled'`
or reaches it through `needs: preflight`, which is skipped for any label that is
not `ci:macos` or `ci:windows`. `static-js` had neither: no guard, and no
`needs`, so it was the one job a label event still started.

Dependabot pull requests are labelled after they open, so each one paid for a
full extra run of this job per label. #845 and #847 ran it five times, #848
three - every repeat with all other jobs correctly skipped.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): group cargo patch bumps into one pull request (#859)

Dependabot opened one pull request per crate, and the crate was rarely the
interesting part of the diff. Each run re-resolves Cargo.lock, so the four open
today - tower-http 0.7.1, anyhow 1.0.104, reqwest 0.13.5, tokio-stream 0.1.19 -
changed 13 to 16 lines each, of which all but one per pull request were
windows-sys dependency edges. The four disagree about that resolution, so
merging any one of them conflicts the other three and each rebase is another CI
run. All four are patch bumps and would have arrived as a single pull request
under this config.

Patch only. Cargo reads the leading non-zero digit as major, so a 0.x minor bump
is a breaking change; egui-phosphor 0.12 -> 0.13 (#733) existed only to follow
egui to 0.35 and could not pass CI alone. Grouping that class would park every
safe bump behind one that cannot merge.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): drop the unused tower-http fs feature (#860)

`fs` gates `tower_http::services::fs` - `ServeDir` and `ServeFile`, which serve
files from disk with directory indexes, Range requests, precompressed variants
and extension-based mime guessing. Strom serves no files from disk: everything
under backend/static is embedded at compile time by rust-embed and returned by
its own handlers, and neither type is named anywhere in the tree. The only
tower-http imports are `cors::{Any, CorsLayer}` and `trace::TraceLayer`.

Removing it drops `http-range-header` from the build graph entirely and nine
dependency edges from tower-http; `mime_guess`, `httpdate`, `futures-util` and
`tokio-util` stay, reached through other crates.

It also settles a question 0.7.1 raises. That release changes `ServeDir::try_call`
to propagate filesystem I/O errors instead of converting them to 404 - a
documented behavioural change that cannot reach a build with the feature off.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): exclude GStreamer from the cargo patch group (#866)

The first grouped run, #861, did not compile:

    error[E0308]: mismatched types
      --> gstreamer-video-0.25.4/src/video_format_info.rs:415:80
        ffi::gst_video_format_info_component(.., plane, &mut comp);
      expected `*mut i32`, found `&mut [i32; 4]`

The group lifts gstreamer-video 0.25.3 -> 0.25.4 while gstreamer-video-sys
stays at 0.25.2 - the string does not appear in the diff at all. 0.25.4 calls a
`-sys` signature newer than the floor it declares, so cargo cannot see the
problem and no lockfile resolution avoids it.

Grouping did not cause that; the same bump would have failed alone. What it did
was park twenty working bumps behind one broken crate. Excluding the two
GStreamer families keeps them arriving as individual pull requests, where one
can be closed without holding the rest - which is the right shape for them
anyway, being this project's core dependency.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: write down the output block input convention (#867)

* docs(claude): write down the output block input convention

Video into an output block must arrive encoded and is refused otherwise;
audio may arrive raw and is encoded with target-suited defaults. A refusal
fails the flow with an element error the operator can act on, instead of a
log line and an unlinked pad. WebRTC outputs and raw-media targets are
exempt from the video rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: note restarting a flow on error as an idea

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(liveaudiorouter): pin the tap to float so the suite measures the router (#870)

`a_fan_in_overload_leaves_the_output_over_full_scale_when_nothing_catches_it`
failed on Check (Linux) with `got 0 dBFS` against an `assert!(peak > 2.0)`,
then passed on a re-run of the same commit with no edits.

The test runs with the fader and soft clipper both off. In that configuration
`caps_out_O` deliberately pins only channels and channel-mask and leaves the
sample format open - the module header says so, and it is the right behaviour
for the block. Nothing else in the harness fixed it either: `tap()` linked
`queue_out_N` straight to `level`, which accepts S16LE through F64LE, onto a
`fakesink` that takes anything. So the bus summed in whatever the runner's
GStreamer happened to negotiate, and an `audiomixer` summing in a fixed-point
format saturates: a fan-in overload reads exactly 0.0 dBFS instead of the sum.

That is a property of the tap, not of the router. The test was measuring the
runner.

Pinning `F32LE` on the tap removes the degree of freedom. It is a test change
only; no block behaviour is touched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(outputs): fail the flow with the block's reason when it refuses an input (#869)

An output block that refused an input only logged it and left the input's
src pad unlinked. Upstream then went not-linked, so the flow's only error
was the source's "Internal data stream error". In the recorder, which
already dropped the refused track's buffers, there was no error at all and
the track was simply missing from the recording.

A shared refuse_input() now posts the reason as an element error from the
block's input identity and sets drop-probability=1.0 on it, so the refused
stream stops quietly and the block's reason is the flow's only error.
video_refusal() and audio_refusal() word the reason the same way in every
block and name the fix: builtin.videoenc for raw video, the encoder's codec
property otherwise.

Applied to RTMP, MPEG-TS/SRT, EFP/SRT, Recorder and TAMS Output. Each
block's accepted codecs are unchanged.

Fixes #840

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(rtmp_output): refuse H.264 in a profile RTMP receivers reject (#871)

flvmux accepts H.264 in any profile, so a 4:2:2, 4:4:4 or 10-bit stream
published without a complaint and was then rejected by the platform, with
nothing in Strom saying so. video_plan now reads the caps' profile and
refuses anything outside the 8-bit 4:2:0 profiles, High and below, through
refuse_input, naming the profile and builtin.videoenc's profile property.
An absent profile is accepted.

The pipeline test's H.264 source now pins I420: left open, x264enc
followed videotestsrc's first format into a profile the block refuses.

Closes #783

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(pipeline): bound the set_state(NULL) join in stop and drop (#874)

An element can park set_state(NULL) indefinitely (splitmuxsink waiting
on a track while holding a pad's stream lock), and both stop() and Drop
joined that thread with no deadline, so POST /api/flows/{id}/stop never
answered.

Both joins now go through run_with_deadline with a 30 s limit. On
timeout stop() logs ERROR, marks the manager wedged and returns an
error; Drop skips its own attempt instead of blocking on the same
transition. The abandoned pipeline leaks, as a wedged one already did.

Refs #804

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(properties): read enum properties on elements and pads as their nick (#875)

The element reader matched the literal type name "GEnum", which no enum
reports, and the pad reader read an enum GValue as i32, which glib
refuses. Both listing calls swallow a failed read, so enum properties
were silently missing from GET .../properties and
GET .../pads/{pad}/properties.

Both paths now share one reader. Enums are read through the GValue and
returned as their nick, the form the write path accepts. Other
unsupported types (flags, caps, ...) still fall out as before.

Refs #770

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(pipeline): give the run_with_deadline panic test room for the panic hook (#878)

The channel disconnects only after the panic hook has run. CI sets
RUST_BACKTRACE=1, so the hook symbolicates a debug binary first, and
under the parallel test run that took longer than the 5 s deadline:
the test saw TimedOut instead of Panicked (seen on PR #876).

The deadline is only an upper bound; the call returns as soon as the
closure unwinds, so raising it to 120 s costs nothing when it passes.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(contributing): write down how quiet draft PRs are handled (#872)

A draft without new work or discussion for 2 weeks is asked whether it
should stay open; with no reply in another week it is closed. A rebase
does not count as activity, and closing keeps the branch.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(tests): measure the mach port reference, not whether the name is free (#873)

Five tests across `thread_handle.rs` and `thread_registry.rs` asserted that a
port name was no longer allocated once the last handle had been dropped. That
is a process-wide property, and `cargo test` runs the backend's tests in
parallel threads: a freed name is exactly what the kernel hands to the next
thread port anyone allocates. A recycled name reads as allocated, so the
assertion fired as "the reference leaked" when nothing had leaked.

Measured on macOS, full `--lib` suite: 2 failures in 10 runs, and 0 in 10 with
`--test-threads=1`. `Build (macOS)` on main hit the same thing at `05863dd`.

The tests now count the port's user references instead, holding a second handle
on the same port for the duration so the name cannot be freed - and so cannot be
recycled - while they run. That measures the invariant the type actually owes:
one `mach_port_deallocate` per `mach_thread_self`, no more and no less.

Three things had to be measured rather than assumed, each wrong on the first
attempt:

- The handle comes from a thread that has exited, so its send right has become
  a dead name. Asking only for `MACH_PORT_RIGHT_SEND` reports 0 references on a
  name holding two.
- Across that transition the kernel reports the same references under both
  rights, so summing them double-counts: a name holding two read as 4. The
  larger of the two is stable on either side of the transition.
- `join()` does not mean the transition is done. Reading while it is in flight
  gave counts of 3 for a name holding two. The helper now waits for the port to
  settle into a dead name before any test reads a count.

Verified: 0 failures in 30 full `--lib` runs, against a ~20% baseline. All five
tests still fail if `MachThreadPort::drop` stops releasing its reference.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(clocks): point to the chrony setup script when the clock is poorly disciplined (#888)

* feat(clocks): point to the chrony setup script when the clock is poorly disciplined

The Clocks panel flagged a Degraded or Unsynced system clock but did not say
how to fix it, and nobody saw it unless they opened the panel. Add the chrony
setup script as a finding in the panel, and warn once at startup on Linux when
the clock is unsynchronized, has no TAI/UTC leap table, or has a max error
estimate above 500 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(clocks): keep file paths out of the chrony setup hint

The hint shows in the web UI, where a repository path reads oddly, and
the Docker path suggested running the script inside the container. It
installs chrony on the host, so say that and leave the location to the
repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(clocks): judge clock health in one place for panel and startup log

The startup warning had its own copy of the panel's thresholds and missed
two of them: state "error" and an offset above 1 ms. Move the assessment
to strom-types so both read the same verdict, and warn at startup with
the same findings the panel shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ports): port pool — reserve port numbers from a Strom-administered set (#852)

* feat(ports): port pool — reserve port numbers from a Strom-administered set

Implements #851.

Nothing in Strom could answer "which ports may I use?". A caller building flows
over the API had to guess, and two flows binding the same UDP port failed only
at start, with an error that said nothing about the collision.

Strom now administers a set of port numbers and hands them out, knowing nothing
about who is asking or what they do with the numbers:

- `GET /api/ports`, and `/api/ports/reservations` with create, list, get, renew,
  release, plus `assign`/`assign/{flow_id}` for the optional flow association
- off until configured: the reservation routes answer 409 with the setting to
  change in the body, and `GET /api/ports` answers 200 with `enabled: false`
- `probe_before_handout` (default on) binds a candidate UDP and TCP before
  handing it out, marks it `blocked` if something outside Strom holds it, and
  logs on the transition rather than per probe
- reservations persist to `port_reservations.json` in the data directory, never
  through the flow storage backend: port numbers are host-local, and a shared
  PostgreSQL serving two Strom nodes would conflate two hosts' port spaces

The pool is a set of port numbers rather than a range. A range is notation the
config expands, which is what lets a pool have holes and lets a blocked port sit
anywhere; `[ports] ports` accepts both `"47100-47199"` and `47250` in one list.
`GET /api/ports` reports the pool as compacted runs and lists only the ports
that are not free, so the response is proportional to what is interesting rather
than to pool size.

No flow path changes. The pool reads the flow list to clean up after deleted
flows, so the dependency runs one way and flow create, update, start and delete
are untouched.

Tests: backend --lib (685 passed, 17 over the pool), tests/port_pool_test.rs (5),
strom-types (79), openapi_test with the snapshot updated. clippy --all-targets
-D warnings clean.

Also driven against a running server: a pool of `48000-48009,48020` grew a
reservation across its hole, kept assigned ports held through reservation
deletion and released them on flow deletion, and — with a real socket held on
48000 by another process — skipped it, marked it blocked, logged once, and
handed it out again once freed.

* fix(ports): reconcile on every route that reads the pool

`GET /api/ports/reservations` and `/{id}` read the pool without reconciling
against the flow list first, so a reservation could be read back still naming a
flow that had been deleted — a port reported as in use by something that no
longer exists. #851 says the pool reconciles "when the pool is read or
allocated from"; allocation and the pool view did, the reservation reads did
not.

Every entry point now reconciles, including renew, assign and unassign, which
return or mutate a reservation view and would otherwise show the same stale
association. The pass is over a handful of reservations, so doing it everywhere
costs less than reasoning about which routes can skip it.

Found by the round-trip test the issue asks for, added here along with the
probe test it also specifies:

- ports return to the reservation when a flow goes and to the pool only when
  the reservation is released — the issue's order, which is what exposed this
- a port held by another process is skipped and marked blocked, binding a real
  socket rather than stubbing the probe. It takes an ephemeral port from the OS
  and pools that port plus the two after it, so it does not depend on a number
  being free on the build machine.

Tests run: backend --lib (685), port_pool_test (7, two new), strom-types (79),
openapi_test. clippy --all-targets -D warnings clean.

* fix(ports): address port pool review feedback

* fix(ports): update assignment conflict OpenAPI snapshot

* fix(ports): preserve unreadable reservations and distinguish disabled pools

* fix(ports): refuse an unknown flow on assign and a garbled probe setting (#890)

- POST .../assign with a flow_id that does not exist now answers 404.
  It used to answer 200 and the next reconcile dropped the association
  without a word.
- STROM_PORT_PROBE_BEFORE_HANDOUT accepts true/false/1/0/yes/no/on/off
  and fails startup on anything else. An unrecognised value used to
  turn the probe off silently.
- Drop "over-reserving is the intended usage" from the sizing notes;
  the reason to reserve ahead is that a reservation cannot shrink.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* chore(deps): take security updates only from Dependabot, keep up monthly (#891)

Version updates are switched off with open-pull-requests-limit: 0.
Security updates stay enabled in the repository settings and keep
arriving as they happen.

Keeping up moves to a monthly dependency round run by the
implementation agent (scripts/agent/DEPS.md): one pull request that
runs cargo update, holds back any crate that breaks the build, raises
direct requirements that build with no code change (one commit each),
and lists what is still behind.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat: add optional structured event logging and JSON stdout format (#789)

* feat: add optional structured event logging and JSON stdout format

Add three logging config keys: stdout_log_format (compact/json),
structured_events, and include_high_frequency_events. When enabled,
selected StromEvent broadcasts are emitted as structured tracing log
records with stable fields (event.name, strom.flow.id) suitable for
log aggregation pipelines.

Add event_type(), flow_id(), and is_high_frequency() accessors on
StromEvent in strom-types. All three are exhaustive, no-wildcard
matches. event_type() returns the serde wire tag, verified by test.
is_high_frequency() consolidates the two divergent classification
lists in EventBroadcaster::broadcast() and the MCP notification
bridge into a single source of truth.

StromEvent itself is unchanged — no new variants, no field changes,
no OpenAPI or WebSocket contract churn.

Co-authored-by: Claude <noreply@anthropic.com>

* test(events): guard the structured_events logging gate, fix test build

Add coverage for EventBroadcaster::broadcast()'s structured-logging
gate: disabling structured_events emits no record, and disabling
include_high_frequency_events drops high-frequency events while still
logging others.

Fix backend/tests/vision_mixer_failed_start_test.rs, which still
called AppState::new() with the pre-refactor 6-arg signature.

Also reword is_high_frequency()'s doc comment: it previously implied
a uniform "more than once per second" rate for every listed variant,
which doesn't hold for events that flood only once fanned out per
connection/element/flow.

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

* fix(tests): update call sites missed by the structured-logging signature change

`EventBroadcaster::new()` and `AppState::new()` gained `structured_events` /
`include_high_frequency_events` parameters, but 4 test files were missed when
updating call sites:

- backend/tests/recorder_stalled_track_test.rs
- backend/tests/rtp_hdrext_aggregation_test.rs
- backend/tests/rtmp_output_pipeline_test.rs
  (switched to the `EventBroadcaster::with_capacity()` convenience constructor)
- backend/tests/bus_signal_watch_balance_test.rs
  (added the two trailing `false, false` args to `AppState::new()`)

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

* fix(tests): update EventBroadcaster constructor in the new gl_memory_link_test

* test(events): assert the enabled path of include_high_frequency_events

The disabled path was already covered, but the enabled path (structured
events on, high-frequency events included) rested on manual testing
only. Extend the existing gate test with a second broadcaster to close
that gap.

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

* fix(tests): use with_capacity in test call sites added on main

Four integration tests that landed on main after this branch's base still
called the one-argument EventBroadcaster::new, which now takes the two
structured-logging flags as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Per Enstedt <per.enstedt@eyevinn.se>

* feat(gst): give pipeline threads a macOS QoS class so they run on P cores (#723)

* feat(gst): give pipeline threads a macOS QoS class so they run on P cores

On Apple Silicon it is the QoS class, not the pthread priority, that decides
whether a thread is eligible for a performance core. Nothing in the tree called
pthread_set_qos_class_self_np, so a streaming thread could be scheduled onto an
efficiency core with nothing to signal it had happened.

The two settings turn out to be mutually exclusive on macOS, in both orders --
pthread_setschedparam makes a later pthread_set_qos_class_self_np return EPERM,
and setting a class first is silently reset to UNSPECIFIED by a later
pthread_setschedparam (both verified on macOS 15 / M2). So this is a
replacement on macOS rather than an addition: the QoS class is the setting that
governs core placement, and macOS no longer calls the priority API at all.
Linux and Windows keep their existing priority paths untouched.

High maps to USER_INITIATED rather than USER_INTERACTIVE. Both are
performance-core classes -- only BACKGROUND is confined to the efficiency
cluster -- but USER_INTERACTIVE is the band Apple reserves for main-thread UI
work, which is where Strom's own native GUI runs. Encoder threads saturate
every core they are given, so sharing that band with the GUI makes the GUI
compete with them at equal priority. Realtime, an explicit request for the
maximum, does take USER_INTERACTIVE, falling back to USER_INITIATED when the
task runs under a QoS clamp and the kernel refuses it with EPERM.

The call goes on the streaming thread itself, in the bus StreamStatus::Enter
handler and the session pad probe, because a thread that sets no class of its
own gets QOS_CLASS_DEFAULT rather than inheriting anything meaningful. Placing
it there also reaches the element-internal workers that never post a
StreamStatus of their own (libx264's frame threads): those are created from the
streaming thread during caps negotiation, after Enter, and a thread created by
a thread that has an explicit class inherits it.

Setting the class is followed by a read-back, so a class that does not stick is
reported as an error instead of leaving a silently unplaced thread.

Incidentally this also retires a macOS priority path that never worked: the
crossplatform value 80 is outside the 15..=47 range macOS accepts, so every
"High" flow logged a warning and reported achieved=false on every thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9VpaRyFRD76GpdtLdAczi

* fix(gst): drop the unreachable Realtime QoS fallback

Realtime now sets USER_INTERACTIVE or reports the error. The EPERM fallback to
USER_INITIATED was never reached: USER_INTERACTIVE was accepted unclamped, under
taskpolicy -b and -c utility/background/maintenance, and in a launchctl job. The
Realtime test now asserts USER_INTERACTIVE exactly, so the mapping is guarded.

The read-back confirms the class was recorded, not where the thread runs: a
task-level clamp overrides the class and it still reads back as set. The comment
now says so.

The per-flow failure warning suggests setcap cap_sys_nice only on Linux, the one
platform where that is the remedy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(gst): state which threads the macOS QoS class reaches

Only the thread that sets a QoS class carries it; threads it creates start at
QOS_CLASS_DEFAULT. Measured in a launchd Interactive job: a USER_INITIATED
streaming thread runs at kernel base priority 37 while the worker pools that
x264enc (threads=4) and svtav1enc create from it stay at 31. The comments no
longer claim encoder workers inherit the class.

In a process that is not a foreground app, USER_INTERACTIVE and
USER_INITIATED both schedule at 37, so Realtime and High behave the same on
the headless server. The mapping comment now says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(liveaudiorouter): show the bus its consumer's format before input (#881)

* fix(liveaudiorouter): show the bus its consumer's format before input

With its fader and soft clipper off, the output bus leaves the sample
format open and takes it from its first input. When the bus negotiates
before any input has caps, which happens whenever an input joins after
the flow started and, on GStreamer 1.24, whenever the mixer's first
timeout fires before PLAYING, it queries downstream instead. That query
passes through capssetter, which answers with its filter or ANY and
never with what is downstream of it. The mixer then picks S32LE blind,
and a consumer that takes a single format without converting refuses
it: not-negotiated from the source.

Answer caps queries on the capssetter's sink pad with what downstream
of it accepts, with the fields the setter stamps opened again. A
consumer that converts still sees every format and the blind pick is
unchanged (S32LE); one that pins a format now gets it.

This is the intermittent not-negotiated failure of the liveaudiorouter
tests on Linux CI. The new tests connect the input only after the bus
has negotiated, which fails without this change on every run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(liveaudiorouter): start the harness interleaver at its first input

A live audiointerleave whose first deadline passes before its inputs'
caps arrive aggregates with no output format and posts "not negotiated".
Starting its output at the first input buffer removes that window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(liveaudiorouter): prefer float; keep the downstream rate off inputs

The caps answer from the output bus's capssetter now lists downstream's
caps twice: first with downstream's rates, so a bus that picks its
output blind picks a rate a non-resampling consumer takes, then with any
rate, so that a rate locked by a mixer further downstream does not
restrict the bus's inputs. The mixer passes the answer on to its own
input pads; carrying the rate alone refused a 48 kHz input joining a
router that feeds another mixer. Once an input has caps, the mixer
still pins its other inputs to that input's rate, as before.

Float formats come first within each, since the mixer fixes its format
from the first entry and a converting consumer lists its own output
format first: the bus would otherwise sum in 16-bit and saturate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(mpegtssrt_input): relink an output when the demuxer replaces its pad (#877)

* test(mpegtssrt_input): reproduce 856 (#856)

Drive the block with caller A on audio PID 0x41, then caller B on 0x42,
in passthrough and decode mode, and assert B's audio reaches the output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mpegtssrt_input): relink an output when the demuxer replaces its pad

A reconnecting caller whose stream moves a PID or changes codec makes the
demuxer replace its source pad. Each output was claimed once and never given
back, so the replacement stayed unlinked and the input stopped with
not-linked. Track the pad each output is linked through: decodebin's
replacement reuses the freed chain, and tsdemux's, which arrives first,
takes over the output linked longest ago with the retiring pad's EOS
dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(mpegtssrt_input): build the event broadcaster with with_capacity

EventBroadcaster::new now takes the structured-logging flags as well (#789).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Agent <agent@claude.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ci(windows): put GStreamer's DLLs ahead of Git's in the test PATH (#892)

* ci(windows): check the GStreamer plugins load before building

srtsink is in the registry on the Windows runner and still fails to
load, so output_refusal_test fails on every merge to main with only
"Failed to load element factory 'srtsink'". Load the plugins the tests
need right after the install and print the real error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(windows): put GStreamer's DLLs ahead of Git's in the test PATH

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(liveaudiorouter): warn when an input's channels differ from its declaration (#889)

* test(liveaudiorouter): specify the declared-channel mismatch warning (#785)

An input whose negotiated channel count differs from its declared
input_<i>_channels must post a warning from that input naming the
property, and a matching input must stay quiet. Nothing implements the
warning yet, so the first test fails here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(liveaudiorouter): warn when an input's channels differ from its declaration

Option D of #785: the declared input_<i>_channels stays authoritative.
A caps-event probe on each input compares the negotiated channel count
with it and posts an element warning naming the property to set, which
the bus watch forwards to clients as a PipelineWarning. Nothing resizes
under a saved routing matrix.

Also part 3 of #785: a folded input row now shows a ring per output
channel that any of its channels is routed to, in both the normal and
the flipped grid, as the comment above it already promised. The flipped
grid keeps one column for a folded input so its rows stay aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(liveaudiorouter): match the warning source in the arm guard

Clippy's collapsible_match rejected the nested if, which failed Check (Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Agent <agent@claude.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(frontend): show units and a generic heading in the block statistics panel (#882)

* fix(frontend): show units and a generic heading in the block statistics panel

The panel rendered values with StatValue::format(), which ignores
StatMetadata.unit, so 40 ms read as "40.00" and packet counters as bare
numbers. Add StatValue::format_with_unit and Statistic::format_value in
strom-types and use them in the panel. DurationNs keeps the unit it
picks itself and Bool stays Yes/No; an empty unit counts as none.

The heading and empty-state hints said RTP, but the panel serves any
block that reports statistics. Make them generic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(types): state the empty-unit rule generically

/rtp-stats emits no Bool statistics on this tree; the empty-unit Bool
stats come from the Audio Bridge helper in #844.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* perf(video): thread t…
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