webrtc-rs 0.20 upgrade — phase 01: pre-work on 0.17¶
Parent: PLAN-webrtc-0.20-upgrade.md
Prompt¶
Every change in this phase compiles, tests, and ships against webrtc-rs 0.17.1. Nothing here bumps the dependency. The purpose is to shrink phase 02 — which is an unavoidably large atomic commit, because the crate does not build between the version change and the end of the port — by moving as much work as possible into commits that can be reviewed and landed normally.
Two rules for anyone executing this phase:
- No behaviour changes. Every step is a refactor. The answer SDP, the RTP output, and the lifecycle signals must be identical before and after. Where that is hard to eyeball, the step says how to prove it.
- If a step cannot be done without touching 0.20 API surface, stop and move it to phase 02. The value of this phase is entirely in it being landable today.
Recommended planning effort for this phase: medium — the research is front-loaded into this document. Recommended effort per step is in the step table.
Situation¶
A correction to the master plan¶
The master plan states that connection_state() at
bridge.rs:861 is a "state accessor used by the reaper". That is
wrong, and the error matters for how this phase is scoped.
connection_state() lives inside a #[cfg(test)] impl block
(bridge.rs:832-866) and is pub(crate). Production code never
calls it. The reaper in ryll/src/web/lifecycle.rs uses
dead_handle() / dead_flag_handle() instead, which are backed
by the Arc<Notify> + Arc<AtomicBool> pair and do not touch
the peer connection at all.
So the production bridge does not need a connection-state
shadow. What needs one is the test clients, which call
connection_state() on a raw RTCPeerConnection
(loopback.rs:238, :248, lifecycle.rs:165, :175,
bridge.rs:1166-1167) — and that is exactly the call that
disappears from the trait in 0.20.
The master plan is corrected in the same commit as this file.
The duplication nobody has had to care about until now¶
There are four near-identical client-side peer connection setups in the tree:
| Site | Lines | Registers H.264 | Polls state | Has DC handler |
|---|---|---|---|---|
bridge.rs in-file test |
904–948 | yes | yes (:1166) |
no |
tests/loopback.rs |
100–216 | yes (inline) | yes (:238) |
yes (:154) |
tests/lifecycle.rs |
88–140 | — | yes (:165) |
no |
ryll/src/web/signalling.rs |
434–492 | — | no | no |
Each one does some subset of: MediaEngine::default(),
register_default_codecs(), H.264 registration, Registry::new(),
register_default_interceptors(), APIBuilder,
new_peer_connection(), two add_transceiver_from_kind() calls,
create_offer(), set_local_description(),
gathering_complete_promise(), local_description(), then a
poll loop on connection_state().
Every one of those calls changes in 0.20. Left as-is, phase 02 rewrites this boilerplate four times, in two crates, with four chances to get it subtly different — and the differences would be invisible until an integration test flakes. Collapsing it to one implementation first is the single highest-leverage thing this phase does.
Note that ryll/src/web/signalling.rs is in a different crate,
so the shared helper has to be reachable across the workspace.
What phase 02 still has to do afterwards¶
For calibration, after this phase lands, phase 02's remaining
work in bridge.rs is roughly: rewrite ~25 use statements,
swap APIBuilder for PeerConnectionBuilder, add
.with_udp_addrs(), add the streams field in three places, and
change one impl block from inherent methods to
PeerConnectionEventHandler. Plus the same treatment once in the
shared test helper instead of four times.
Steps¶
1a — Capture the 0.17.2 performance baseline¶
Before touching anything. Phase 04 needs something to compare against, and it cannot be captured after the bump.
Run a real --web session against a real SPICE guest for long
enough to reach steady state (20 minutes is enough), and record:
process RSS, per-thread CPU from the runtime metrics, the latency
HUD's distribution, and the video pump's dropped-packet debug
count. Record the commit SHA, the guest, the resolution, and the
browser alongside the numbers, because a baseline without its
conditions is not a baseline.
Write the numbers into this file under a new "Baseline" heading rather than a scratch file, so they are still there when phase 04 runs.
1b — Promote rtp to a direct dependency¶
bridge.rs:43-47 imports H264Payloader, OpusPayloader,
Header, Packet and Payloader through the webrtc::rtp
re-export, which does not exist in 0.20.
shakenfist-spice-renderer already depends on the standalone
crate directly (rtp = "0.17", with a comment at
shakenfist-spice-renderer/Cargo.toml:131-136 explaining why),
so this step just brings the webrtc crate into line with an
existing project decision.
Add rtp = "0.17" to shakenfist-spice-webrtc/Cargo.toml,
change the five imports from webrtc::rtp::* to rtp::*, and
rewrite the comment at Cargo.toml:15-17 — it currently explains
that webrtc 0.17.1 is pinned because it re-exports a matching
rtp, which stops being the reason once we depend on rtp
directly.
Behaviour proof: the types are literally the same types; this is
a path change. cargo tree -p shakenfist-spice-webrtc -i rtp
should show one rtp version, not two.
1c — Extract the shared test-client helper¶
The big one. Create shakenfist-spice-webrtc/src/test_client.rs
behind an optional test-support feature, exposing a
TestPeer type that covers the union of what the four call sites
need:
TestPeer::builder()with an opt-in seed datachannel. The sites also differ in whether they register H.264 — but that turned out to be unobservable (see "What landed" below), so the builder does not expose it.offer_and_gather() -> Result<String>— create offer, set local, wait for gathering, return the resolved SDP.set_remote_answer(sdp).wait_until_connected(timeout).- Accessors for the raw
Arc<RTCPeerConnection>so a site that needs something the helper does not cover (loopback'son_trackcounters) can still reach through.
shakenfist-spice-webrtc gets test-support = [] in
[features]; ryll enables it on its dev-dependency.
Migrate all four call sites. loopback.rs keeps its on_track
counter wiring and DC echo handler locally — those are genuinely
test-specific — but gets its PC and its SDP dance from TestPeer.
Behaviour proof: make test passes, and the SDP each site
produces is unchanged. Capture one offer SDP per site before and
after and diff them; they should differ only in ICE ufrag/pwd,
SSRCs, and fingerprints, which are random per PC.
This step is worth doing carefully. If the helper ends up so general that every call site passes a different combination of flags, it has failed — prefer two small helpers over one parameterised one.
What landed, and how it differed from this plan¶
Two things were wrong in the sketch above.
The #[cfg(test)] impl on WebrtcBridge does not fold away.
It serves a different purpose than client-PC emulation:
control_datachannel_roundtrips_messages drives a bridge-to-
bridge exchange, so create_offer_and_gather, set_remote_answer
and connection_state are needed on the bridge itself. It stays.
That means WebrtcBridge::connection_state still calls
RTCPeerConnection::connection_state, and step 1d cannot retire
that call on its own — step 1e picks it up instead, reading the
BridgeEvents shadow.
The H.264 registration knob is unobservable, so it does not
exist. Three sites registered H.264 explicitly and ryll's
signalling test did not, which looked like a genuine difference
worth a builder flag. It is not: register_default_codecs()
already registers H.264 at PT 102, and webrtc-rs drops
register_h264's explicit re-registration as a duplicate payload
type. The two offers are byte-identical, asserted by
register_h264_is_redundant_with_default_codecs. The builder
therefore has exactly one knob — the seed datachannel — and
TestPeer always mirrors the bridge's registration.
That test also records a related discrepancy found on the way,
deliberately left alone: the default PT 102 entry carries
profile-level-id=42001f while register_h264 asks for
42e01f, so the bridge negotiates one profile-level and its
encoder emits another. Browsers tolerate the constraint-set
difference. It predates this work and is not the port's problem,
but it is worth someone's attention eventually.
One further simplification fell out: with signalling.rs off
its hand-rolled peer, ryll has no direct webrtc:: reference
left at all, so its webrtc = "0.17.1" dev-dependency is gone.
Phase 02 now has one manifest to bump instead of two.
1d — Shadow connection state in the helper¶
With 1c landed there is one place to change. Register
on_peer_connection_state_change inside TestPeer::builder(),
keep the latest state in an Arc<Mutex<RTCPeerConnectionState>>
(or an AtomicU8 with a conversion, if clippy prefers it), and
have wait_until_connected read the shadow.
Behaviour proof: tests still pass, and — important — still
actually wait, rather than passing because the shadow defaults
to Connected. Assert the shadow starts at New.
Note that this does not by itself retire
RTCPeerConnection::connection_state() from the workspace, as
this plan originally claimed. WebrtcBridge::connection_state
(the #[cfg(test)] one, used by the bridge-to-bridge roundtrip
test) still calls it. Step 1e retires that one by reading the
BridgeEvents shadow, so the "no call sites remain" check
belongs at the end of 1e rather than here.
1e — Collapse the bridge's three callbacks into one struct¶
bridge.rs registers three callbacks at :258, :312 and
:328, the last nesting a fourth at :335. Introduce a
BridgeEvents struct holding what they capture today —
encoder_control: mpsc::Sender<EncoderControl>,
dead: Arc<Notify>, dead_flag: Arc<AtomicBool>,
incoming_tx: mpsc::Sender<Vec<u8>> — with three async methods:
async fn on_state_change(&self, state: RTCPeerConnectionState)
async fn on_control_message(&self, data: Vec<u8>)
async fn on_remote_data_channel(&self, dc: Arc<RTCDataChannel>)
Keep registering them through the 0.17 closure API; each closure
becomes a two-line delegation to an Arc<BridgeEvents>. Phase 02
then adds impl PeerConnectionEventHandler for BridgeEvents and
deletes the closures — the bodies never move again.
Everything those closures capture is already constructed before
the PC exists (dead and dead_flag at :247-248, incoming_tx
at :309), so this does not reorder construction. It does mean
control_dc.on_message and the nested remote_dc.on_message
both delegate to the same on_control_message, which is already
the intent — the comment at :295-308 says so explicitly.
Behaviour proof: the sticky-flag semantics are the subtle part.
dead_flag.swap(true, ...) at :282 guards notify_waiters()
so only the first terminal transition fires. Keep that inside
on_state_change and keep tests/lifecycle.rs green — it
asserts both the first wait_for_dead resolving and the second
returning immediately via the fast path.
1f — Handler-driven ICE-gathering completion¶
The riskiest item in phase 02, de-risked here.
accept_offer (:429-430) calls gathering_complete_promise(),
which is gone from the 0.20 trait. 0.17 already has
on_ice_gathering_state_change, so the replacement design can be
built and validated today:
- Add
gathered: Arc<Notify>+gathered_flag: Arc<AtomicBool>toBridgeEvents, following the same sticky pattern asdead/dead_flag— the reasoning at:239-248applies identically, and a late subscriber here would hangaccept_offerforever. - Raise them from a new
on_ice_gathering_state_changehandler when the state reachesComplete. - Rewrite
accept_offerto await that signal instead of the promise. - Do the same for
TestPeer::offer_and_gather(which is why 1c comes first).
Behaviour proof — and this one deserves real rigour, because a subtly-early signal produces an SDP that is missing candidates and fails only on some networks:
Run accept_offer 20 times before and after, and assert the
answer SDP contains the same number of a=candidate: lines each
time. Additionally assert that the gathering signal fires after
local_description() returns a description containing at least
one candidate — if on_ice_gathering_state_change can fire
before the local description is updated, that ordering bug exists
in 0.17 too and we want to find it now, not in phase 02.
1g — Re-measure and confirm no regression¶
Repeat 1a's measurement on the phase-01 tip. Steps 1b–1f are all refactors, so the numbers should be within noise of the baseline. If they are not, something in this phase changed behaviour and the phase is not done.
Step table¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1a | low | haiku | none | Run ryll --web against a real SPICE guest for 20 min, record RSS, per-thread CPU, latency HUD distribution, and video-pump drop count into a "Baseline" section of docs/plans/PLAN-webrtc-0.20-upgrade-phase-01-prework.md, along with commit SHA, guest, resolution and browser. Do not change any code. |
| 1b | low | sonnet | none | Add rtp = "0.17" to shakenfist-spice-webrtc/Cargo.toml; change the five imports at bridge.rs:43-47 from webrtc::rtp::* to rtp::*; rewrite the now-stale comment at Cargo.toml:15-17. Mirror the existing declaration in shakenfist-spice-renderer/Cargo.toml:131-136. Verify cargo tree -p shakenfist-spice-webrtc -i rtp shows a single version. |
| 1c | high | opus | worktree | Create shakenfist-spice-webrtc/src/test_client.rs behind a test-support feature exposing a TestPeer builder, and migrate all four client-PC setups to it: bridge.rs:904-948, tests/loopback.rs:100-216, tests/lifecycle.rs:88-140, ryll/src/web/signalling.rs:434-492. Fold away the #[cfg(test)] impl at bridge.rs:832-866. ryll enables the feature on its dev-dependency. Sites keep their own on_track / DC-echo wiring. Prove offer SDPs are unchanged modulo per-PC randomness. Read the "Extract the shared test-client helper" section of this plan first — it explains what must stay configurable and warns against over-generalising. |
| 1d | medium | sonnet | none | Inside TestPeer, register on_peer_connection_state_change and shadow the latest state; make wait_until_connected read the shadow. Afterwards grep -rn "\.connection_state()" --include="*.rs" must show no calls on a raw RTCPeerConnection. Assert the shadow starts at New so the tests still genuinely wait. |
| 1e | high | opus | none | Introduce BridgeEvents in bridge.rs holding encoder_control, dead, dead_flag, incoming_tx, with async methods on_state_change, on_control_message, on_remote_data_channel. Reduce the closures at :258, :312, :328 (and the nested one at :335) to delegations. Preserve the dead_flag.swap guard at :282 exactly — tests/lifecycle.rs asserts both the first wait_for_dead resolving and the second taking the sticky fast path. Do not reorder construction. |
| 1f | high | opus | worktree | Add gathered: Arc<Notify> + gathered_flag: Arc<AtomicBool> to BridgeEvents using the same sticky pattern as dead/dead_flag; raise from a new on_ice_gathering_state_change handler on Complete; rewrite accept_offer (bridge.rs:429-430) and TestPeer::offer_and_gather to await it instead of gathering_complete_promise(). Validate per the "Behaviour proof" in this plan's 1f section — 20 runs, identical a=candidate: counts, and confirm the signal cannot fire before the local description carries candidates. |
| 1g | low | haiku | none | Repeat 1a's measurement on the phase-01 tip and append it beside the baseline. Flag any difference outside noise as a regression — every step in this phase is meant to be behaviour-preserving. |
Dependencies: 1a first. 1c before 1d and before 1f. 1e before 1f. 1b is independent and can go any time. 1g last.
Status¶
| Step | State |
|---|---|
| 1a — 0.17.2 baseline | Done — see "Baseline" below |
1b — rtp direct dependency |
Done |
1c — shared TestPeer |
Done |
1d — state shadow in TestPeer |
Done |
1e — BridgeEvents |
Done |
| 1f — handler-driven gathering | Done |
| 1g — re-measure | Done — agrees with 1a within noise |
Plus one unplanned commit: the wait_for_dead lost-wakeup fix
described under "Found during execution".
Baseline¶
Captured 2026-08-13. Two 20-minute soaks under identical
conditions: 1a on the 0.17 tip (develop ce740e26, which
resolves webrtc 0.17.2 via lockfile maintenance; the manifest
still says 0.17.1), 1g on the phase-01 tip (9ea7cfb0). Phase 04
must reproduce these conditions to compare against these numbers.
| Metric | 1a (ce740e26) |
1g (9ea7cfb0) |
|---|---|---|
| RSS start → end | 154 → 215 MB | 161 → 197 MB |
| RSS max | 226 MB | 197 MB |
| CPU, all threads, whole run | ~1.4% of one core | ~0.9% of one core |
| Video pump drops | 0 | 0 |
| Audio pump drops | 0 | 0 |
| Reaper events | 0 | 0 |
| ryll alive at end | yes | yes |
| Host CPU busy%, mean (max) | 8.3 (9) | 10.4 (29) |
Verdict: no regression. The phase-01 tip is at-or-below the baseline on both memory and CPU, and the differences are within run-to-run noise at this load. Both runs show RSS growing through the run (plausibly ring buffers and caches filling to their caps); the growth is common to both and is itself part of the baseline shape phase 04 should expect.
Conditions¶
- Guest:
testdata/uefi-latency-guest.qcow2underqemu-system-x86_64(q35, 128 MB, OVMF, QXL,-display none), SPICE onlocalhost:5900with ticketing disabled, fixed at 1280x800. Booted once and reused for both runs. - Guest behaviour (discovered during 1a): any keypress advances a fixed 8-colour cycle — teal, red, magenta, yellow, grey, black, blue, green — as a full-screen repaint, sometimes via a full video-mode reset (SPICE surface destroy → 640x480 → recreate 1280x800). One step in eight is black, so the viewer legitimately shows ~30 s of black every 4 minutes.
- Driver: one QMP
sendkeyevery 30 s; the guest was stepped to teal before each run so both runs start at the same cycle position. A 5 s cadence does not work: the mode-set churn outruns the stream's few-second recovery and the viewer stays black permanently (see "Found during execution"). - Viewer: Debian Chromium on the same host, fresh profile,
--disable-features=WebRtcHideLocalIpsWithMdnsand--autoplay-policy=no-user-gesture-required, signalling over loopback. Firefox 140 ESR could not establish ICE in this environment at all (its offers carried no usable candidates, with or without the mDNS-obfuscation and loopback prefs) and cannot be the phase-04 viewer on this host. - ryll:
--web --direct localhost:5900, dev-profile build viamake build,RUST_LOG=info,shakenfist_spice_webrtc=debug,ryll=debug(the drop counters only log at debug). Encoder negotiated 1280x800@30fps in both runs. - Sampling: RSS (
/proc/<pid>/status) and per-thread CPU (/proc/<pid>/task/*/stat) every 30 s, with whole-host CPU busy% and load average recorded per sample so contamination from other workloads on the shared machine is visible in the record. The host stayed quiet through 1a; 1g saw one brief external spike (max 29% busy on 16 cores), not enough to move the numbers.
Deviations from the step brief¶
The brief asked for "per-thread CPU from the runtime metrics"
and "the latency HUD's distribution". Both are GUI-mode-only:
the auto-snapshot loop is spawned from app.rs and the web
shell has no latency HUD, so neither exists under --web.
Substituted: external /proc sampling as above, and no latency
distribution (RTT over loopback is ~0 and browser-side WebRTC
stats were not scraped). Phase 04 must measure the same way for
the comparison to hold.
This is also a light workload — a solid-colour full-screen repaint every 30 s is nearer idle-with-bursts than a busy desktop (compare the 0.2–50 Mbit/s range seen in real test sessions). It is what is reproducible unattended; treat the numbers as a floor-shape baseline, not a stress result.
Effort¶
Two days, up from the one day the master plan estimated. The increase is step 1c, which the master plan did not account for — the four-way duplication only became visible on a close read of the test files.
This is a good trade rather than a slip: 1c moves work out of phase 02's atomic commit, where it would have been four parallel rewrites reviewed as one diff, into a normal reviewable refactor that runs against a test suite which still works. Phase 02 should come down by at least as much, and its risk comes down more.
Acceptance¶
make testandpre-commit run --all-filespass at every commit in the phase, not just the tip.webrtc = "0.17.1"is unchanged in both manifests. If this phase bumped the dependency, it did the wrong thing.- No call to
RTCPeerConnection::connection_state()orgathering_complete_promise()remains anywhere in the workspace. bridge.rsregisters its callbacks throughBridgeEventsrather than inline closures, and every closure body is a delegation.- One client-PC construction path exists, not four.
- The 1a and 1g measurements agree within noise.
Open questions¶
-
~~Does
on_ice_gathering_state_changefireCompletebeforelocal_description()carries the candidates in 0.17?~~ Answered: no, the ordering is safe. Step 1f landed andgathering_signal_fires_after_local_description_is_populatedasserts the local description carriesa=candidate:lines the instantwait_for_gatheringreturns.accept_offer_answer_carries_all_candidatesadditionally runs the full exchange repeatedly and asserts a non-zero candidate count every time (5 iterations in the smoke tier; the 20-run invariant-count soak is gated behindRYLL_GATHERING_SOAK=1after PR #267 review flagged the exact-equality check as coupled to host interface churn). The sticky-signal design needs no second condition, and this open question no longer inflates phase 02's estimate. -
~~Should
test-supportbe a feature or a separate crate?~~ Answered during 1c: a feature. It is lighter, matches the workspace, and the reasoning is recorded in the crate'sCargo.tomlcomment. The accepted consequence is thatsrc/test_client.rs(~500 lines) ships in the published crates.io tarball forshakenfist-spice-webrtc; it has no production-surface impact because the module is gated behind#[cfg(any(test, feature = "test-support"))]and the feature is off by default. If the tarball weight ever grates, the-testkitcrate split remains available. -
Does
loopback.rs'son_trackwiring belong in the helper? It is currently the only site with it, so the plan leaves it local. If phase 02 or a later plan adds a second consumer, revisit rather than generalising speculatively.
Found during execution¶
wait_for_dead has a lost-wakeup race¶
Not introduced by this phase, but directly adjacent to it and worth fixing before someone copies the pattern again.
WebrtcBridge::wait_for_dead (and the equivalent inline logic in
ryll/src/web/lifecycle.rs) does:
Notify::notified() does not register interest until the future
is first polled, so there is a window between the flag load and
the first poll. If the peer connection reaches a terminal state
inside that window, notify_waiters() fires with nobody
registered, and the subsequent await blocks forever — the reaper
never tears the bridge down.
wait_for_gathering, added in step 1f, avoids this by calling
Notified::enable() before the flag check, which registers up
front so a notification landing in the window is still delivered.
Fixed, in its own commit after 1f rather than folded into it:
WebrtcBridge::wait_for_dead and the reaper's inline equivalent
in ryll/src/web/lifecycle.rs both now enable() before checking
the flag. The reaper case is the one with teeth — a lost wakeup
there leaves a dead bridge and its encoder pipeline running until
the process exits.
This is a genuine production bug fix that happens to have been found by porting work, not a refactor. It is called out here so it is not mistaken for one.
The PR #267 review then pointed out that the fix left the
correct-but-subtle pattern copy-pasted at four sites — the exact
duplication that produced the original bug. The wait and raise
halves are now consolidated into
shakenfist_spice_webrtc::StickySignal (src/sticky.rs), used
by wait_for_dead, wait_for_gathering,
TestPeer::offer_and_gather, and the ryll bridge reaper (which
takes a single dead_signal() handle in place of the old
dead_handle()/dead_flag_handle() pair). The type carries unit
tests for the pre-raised fast path, the lost-wakeup schedule
itself (raise after the waiter registered but before it was
woken), and multiple/repeat waiters — coverage the inline copies
could not have without a running ICE stack.
The notify path versus the fast path¶
Whether wait_for_gathering's first call blocks on the
notification or finds the flag already set is timing-dependent,
so the acceptance tests cannot control which path they exercise.
gathering_signal_fires_after_local_description_is_populated
therefore calls it a second time, after gathering has certainly
finished, to guarantee the sticky fast path is covered at all.
Without that, a regression making late callers hang could pass
the suite.
Sustained surface churn wedges the web stream (pre-existing)¶
Found while running the baseline soaks, not introduced by this phase. The uefi-latency-guest performs a full video-mode reset (SPICE surface destroy → 640x480 → recreate 1280x800) when its colour changes, and the browser stream takes a few seconds to recover from each one. Driving a colour change every 5 s churns faster than recovery, and the viewer then shows black permanently — it never resurfaces even though ryll's logs show the surfaces cycling and the encoder restarting. At a 30 s cadence recovery completes every time. Real desktops rarely mode-set repeatedly, so this is a robustness gap rather than a dogfooding blocker. Tracked as Q5 in OPEN-QUESTIONS.md: the encoder/stream should survive surface churn at any cadence, and the right time to diagnose is after the 0.20 port rewrites the adjacent paths.