Skip to content

fix(js): Safari playback/publish support, connection reliability, and a capture-releasing mute#2163

Open
fperex wants to merge 19 commits into
moq-dev:devfrom
fperex:dev
Open

fix(js): Safari playback/publish support, connection reliability, and a capture-releasing mute#2163
fperex wants to merge 19 commits into
moq-dev:devfrom
fperex:dev

Conversation

@fperex

@fperex fperex commented Jul 11, 2026

Copy link
Copy Markdown

Batch of browser fixes developed on the fperex fork, focused on making Safari a first-class client without touching Chrome/Firefox behavior (Safari-only changes are gated on UA predicates), plus connection-reliability fixes that apply everywhere.

Safari transport

  • Safari is routed onto the WebSocket (qmux) fallback unconditionally: reading WebTransport.datagrams.readable hard-kills the Safari session with no catchable error, so the QUIC race is never entered (pickTransport in @moq/net).
  • Firefox keeps its existing WebSocket routing (drops server-initiated bidi streams).
  • Both support matrices (@moq/publish/support, @moq/watch/support) report webtransport: "partial" on Safari/Firefox to surface the degraded path.

Connection reliability

  • Connection.Reload now reconnects when an established session closes, not just when a connect attempt fails. closed resolves (never rejects) on qmux, so a dropped session previously left a dead established and a permanent "connected" status, with Safari/Firefox never recovering from any drop.
  • A Safari pagehide no longer latches the connection off permanently; the connection closes eagerly on pagehide/freeze (so the relay unannounces immediately) and resumes on pageshow/visibility.
  • announcedGenerations tracks a per-path announce generation so a watcher re-consumes a same-name republish even when the unannounce+announce coalesce.
  • Stream teardown classification (isStreamAbort): routine lifecycle resets (unsubscribe, handover, session close) log at debug; client-actionable faults (auth, not-found, protocol, unroutable) still warn, on every run-loop catch across net/hang/watch.

Publish: audio privacy mute

  • muted now stops audio capture while the encoder pipeline stays up and publishes true silence via a keep-alive graph. The catalog never drops or reshapes the audio track, so watchers keep their audio subtree instead of rebuilding it (no video freeze on mute).
  • Per source: a microphone track is stopped outright (track.stop(), so the OS recording indicator turns off); screen/file audio is detached from the encoder instead, since stopping a share track would kill the share and force a re-prompt.
  • Selecting a source while muted never touches the microphone at all; unmuting acquires it.

Publish: capture/encode

  • Safari hardware video encode; video capture runs in a Worker where supported (safariWorkerCapture, WebKit 18+).
  • Opus catalog now always advertises 48 kHz (RFC 6716 decoder-side rate). Non-48 kHz capture (Safari captures at the hardware rate and ignores the context-rate request) is stream-resampled before encode with cadence-snapped timestamps.
  • Video catalog folds in the encoder-produced codec string and hvcC/av1C description after the first keyframe, tagged against the requested codec+dimensions so bandwidth-driven bitrate churn (~10x/s) never flaps the description.
  • Camera/microphone re-acquire on track ended (all browsers); Safari-gated recovery for a backgrounded tab leaving the camera stuck muted.
  • Stats tab graphs a measured, transport-agnostic upload bitrate from encoded bytes.

Watch: decode robustness

  • Budgeted in-place decoder restarts (audio + video) with config-supersession checks, keyframe resync on DataError, and a decode-queue cap.
  • Audio: rate-mismatch resampling, timestamp snapping, discontinuity re-anchoring on resubscribe, AudioContext resume on user gesture.
  • Catalog subscription that ends unexpectedly is reopened with bounded backoff; status settles to "offline" when the budget is exhausted.
  • New unsupported-codec indicator in the UI overlay (catalog has renditions but none decodable here).

Public API changes (js/*)

Breaking (why this targets dev):

  • @moq/net Connection.Established.transport — new required readonly member on a public interface. Additive for consumers; a compile break for external implementers of the interface.

Additive:

  • @moq/net: isStreamAbort (root export), Connection.Transport type, Connection.Reload.announcedGenerations.
  • @moq/hang: Util.Hacks.isSafari, Util.Hacks.safariWorkerCapture (plus pure detect* cores primarily consumed internally/by tests).
  • @moq/publish: Video.Encoder.bytesEncoded.
  • @moq/watch: optional announcedGenerations in the Broadcast constructor options bag, Video.Source.output.unsupported, AudioBuffer.wait(timestamp, signal?).

Behavior changes on existing surface: Safari/Firefox transport routing, reconnect-on-close, mic-releasing muted, 48 kHz Opus catalog rate, isSupported() matrices (details above).

Known upstream issue

Testing this PR on Safari/Firefox (which it routes onto the WebSocket/qmux fallback) surfaces Unhandled rejection: TypeError: ReadableStreamDefaultController is not in a state where chunk can be enqueued when a peer reloads while connected. This is a known @moq/qmux <= 0.3.1 bug, already fixed upstream in moq-dev/web-transport (release pending); it is not introduced by this PR and the pin can be bumped when the release ships.

PR Link: moq-dev/web-transport#285

Test plan

  • just js check and just js test green (544 tests across 8 packages; new unit tests for transport picking, error classification, cadence/resampler math, video catalog capture, config supersession, timestamp snapping, container consumer).
  • Headless-Chrome (fake media device) end-to-end privacy proof: selecting a source while muted performs zero getUserMedia calls; every mute ends the mic track; the audio catalog stays present and shape-identical across mute cycles; exactly one AudioContext across the full cycle (pipeline survives mute).
  • Manual Safari verification on device: mic indicator off on mute, watcher video does not freeze across publisher mute/unmute.

fperex added 17 commits July 9, 2026 14:53
Publish: hardware H.264 encode with worker capture and honest support labels,
48 kHz Opus with cadence-snapped timestamps and capture-rate resampling.
Watch: audio resampling to the context rate, timestamp snap, gesture-driven
AudioContext resume with a running-gated graph, decoder restart budgets
(audio + video) with codec-switch supersede detection and keyframe resync,
decode-queue backpressure, unsupported-codec detection with a UI notice,
stall/visibility reconnect recovery, and announce-generation republish
re-consume. Net: code-aware isStreamAbort logging so real subscribe/publish
faults surface while routine stream teardown stays at debug.
Document the getting-started flow (nix develop / just check / just fix), the
main-vs-dev branch-targeting rule (base on and target dev unless a change is
purely additive), the Cross-Package Sync expectation, code-style rules (no em
dashes, document exported symbols, no AI attribution), and the review flow.
Safari cannot use WebTransport on moq-lite-05. Reading
`WebTransport.datagrams.readable` tears down the whole session, and
`Connection` always reads it once the version carries datagrams, so the
session dies and Reload loops on "connection error". The read cannot be
guarded, only avoided.

Route Safari to the existing WebSocket/qmux transport instead, next to the
Firefox force already in connect.ts. qmux implements `datagrams` itself, so
Safari never touches the native API. Nothing in hang/publish/watch sends or
receives datagrams today, and Firefox has run this path since lite-05 landed.

Safari therefore has no `getStats()`, so `sendBandwidth` stays undefined and
the encoder skips its bandwidth cap, exactly as Firefox already does. The
publish support panel now reports WebTransport as "partial" for Safari to
surface the degraded path, and the browser-support docs say so.

Also widen `isStreamAbort` to treat a write-after-close as routine teardown:
over qmux it surfaces as a generic Streams-API error rather than a coded
RESET_STREAM. Chromium and Firefox say the stream is "closed or closing";
Safari throws InvalidStateError.
…op the reconnect storm

Extract transport selection into a pure `pickTransport(userAgent, hasWebTransport)`
so "Safari never gets WebTransport" is covered by a test rather than by reading
one boolean expression. The eleven-user-agent matrix pins the cases that matter:
Safari 26 with WebTransport present still returns websocket, and iOS Chrome
(CriOS) and iOS Firefox (FxiOS) fall through to the WebKit branch because they
report neither "chrome" nor "firefox". Chrome and Firefox are unchanged.

Expose the negotiated transport as `Established.transport`, derived from the
race winner, and render it in the demo. The Network panel previously hardcoded
the word "WebTransport", so a Safari session on WebSocket looked like it was on
WebTransport. `connect()` now reads that same `transportOf()` for its own log,
so the console line and the field cannot disagree.

Fix fullscreen on Safari: `#fullscreen()` compared `document.fullscreenElement`
against the shadow-internal `.player`, but the spec retargets that to the shadow
host. Chrome and Firefox were rescued by `ShadowRoot.fullscreenElement`, which
Safari does not expose, so the video never filled the screen. Add a `:fullscreen`
match alongside every existing check rather than replacing them.

Remove the Safari stall/visibility reconnect recovery, and `Reload.reconnect()`
with it. A hidden `<moq-watch>` stops rendering, so `stalled` is always true on
return, and the recovery forced a full reconnect on every tab switch. It existed
to recover a wedged WebTransport session, which Safari can no longer have.
Neither symbol exists upstream, so this moves reload.ts back toward it.

Also report WebTransport as "partial" on Safari in both support panels, since
Safari defines it but never uses it.
The message heuristic ran before the coded-fault check, so a WebTransportError
carrying a client-actionable code was reclassified as routine teardown whenever
the browser's prose happened to mention a closing stream. And `invalid state`
matched any message containing that substring, so one of our own ordering bugs
throwing "the decoder is in an invalid state" was downgraded to debug and never
seen. Decide on the coded reset first, and key Safari's InvalidStateError on
`err.name` rather than on prose.

Gate the two `probe stream error` logs on isStreamAbort as well. They had been
downgraded to an unconditional debug, which hid an auth or protocol fault on the
bandwidth side channel on every browser, not just during teardown.

Restore the Jan-Ivar attribution and source link for the rAF capture polyfill,
which the Worker-capture rewrite dropped even though that code still ships as
`rafTrackProcessor`. Restore the upstream note about disconnecting the worklet
to save power, and correct the `#expectedNext` comment: the timestamp snap runs
on every frame, not only on the resample path.
`pagehide` set `#suspended`, but only a `pageshow` with `persisted === true`
cleared it. Safari fires `pagehide` when it freezes a page and can resume that
page without ever firing a persisted `pageshow`, so `#suspended` stayed true,
`#active` stayed false, and the element's Reload was disabled forever. The
connection never came back and never retried.

In the demo this reads as "Connected" next to "Offline": the page's own Reload
is constructed with `enabled: true` and keeps its session, while the
<moq-watch> tile's Reload is gated on `#active` and is permanently disabled.
Throughput sits at 0 and the relay sees no further connection attempts.

Clear `#suspended` on any `pageshow`, and on the page becoming visible again,
so every path that sets it has a counterpart that clears it. Chrome and Firefox
never hit this: they do not fire `pagehide` on a tab switch.
Closing a session fails every stream still open on it. qmux words that
"Connection closed", optionally carrying the peer's CONNECTION_CLOSE code, and
isStreamAbort recognised none of those forms. So tearing a connection down
warned on the probe stream, on the WebSocket fallback, for every viewer that
stopped watching.

Apply the same rule a stream reset gets: routine unless the peer signalled a
client-actionable fault code. A close carrying Unauthorized or
ProtocolViolation still surfaces at warn; a plain close, a zero code, and an
abnormal WebSocket closure do not. Native WebTransport already reported the
equivalent as a WebTransportError with no streamErrorCode.

Safari's Error.stack omits the message line, which is why this only ever showed
up as a bare stack in the console.
`Established.closed` resolves on every session end. qmux always resolves it
(`#closedResolve` in its `#close`), whatever the close code, and WebTransport
rejects only on an abnormal close. So a dropped session fell out of `#connect`'s
try block without ever reaching the catch, and nothing scheduled a retry.

The connection then stayed dead forever: `established` kept pointing at the dead
session, `status` still read "connected", and no further attempt was made. In the
demo this reads as a green "Connected" pill next to an "Offline" broadcast, with
throughput at 0, an empty round-trip graph, and no new "connected via WebSocket"
line in the console.

Safari and Firefox are forced onto the qmux WebSocket by `pickTransport`, so for
them this hit on every drop and nothing short of a page reload brought playback
back. On WebTransport an abnormal close rejects and did recover; a clean one did not.

Treat a resolved `closed` as a disconnect: clear `established`, report
"disconnected", and retry on the existing backoff ladder. Only reset that ladder
once a session has stayed up for STABLE_CONNECTION_MS, so a relay that accepts us
and drops us straight away (bad auth, shedding load) is not hammered once a second
forever. Settle the teardown race with a sentinel rather than a falsy check, and
pin `effect.cancel` and `effect.abort` per run, since a rerun swaps both before it
awaits the spawn.
`#runCatalog` subscribed once and, on any end of its fetch loop, wrote
status="offline". The effect tracks only enabled, catalogFormat, name and
output.active, none of which change when just the catalog stream is reset, so
nothing ever reopened the subscription. A transient reset on a live connection
stranded the tile offline for good.

Reopen it on a bounded backoff, but only when the loop ended with a stream reset
(the subscription was killed under us by a slow-consumer drop, a relay bounce, or
a publisher handover). A clean end means the publisher stopped, and a coded fault
fails identically every attempt, so both still report offline immediately rather
than stalling the badge behind a retry ladder.

Every bail-out now reports offline as well: a pending retry leaves the status at
"loading", so a rerun that finds nothing left to subscribe to has to settle it or
the tile spins forever. The net layer already drops a closed track from its cache,
so resubscribing the same consumer works; guard the closed-broadcast case, since
subscribing to one throws.
The overlay printed `err.stack || err.message`. JavaScriptCore and SpiderMonkey
build `stack` as a bare frame list with no leading "Name: message" line, so on
Safari and Firefox the message was dropped entirely and a qmux teardown surfaced
as an anonymous stack with no error code in it.

Prepend "Name: message" when the stack does not already carry it. Keying on the
stack contents rather than on a user-agent check keeps V8, which does include the
header, from printing it twice.
…eline

Muting used to gate audio capture: it stopped the microphone track, closed the
AudioContext + worklet + AudioEncoder, closed the audio/data track producer, and
cascaded the catalog to drop its audio section. On the watcher that meant a full
audio-subtree rebuild (AudioContext + worklet + ring buffer) plus head-of-line
contention on the shared WebSocket, which froze video.

Mute is now a soft mute: audio capture stays live and the encoder ramps its gain
to zero, so a muted broadcast keeps publishing its audio track as silence and the
catalog never flaps. #audioEnabled is constant-true (nothing is captured until a
source is selected, so an idle element still never touches the mic), and state.muted
feeds the encoder's existing #runGain via broadcast.audio.muted.

Also scope the four #runSource source-proxy effects to the passed child effect
(effect.run/effect.set) instead of registering on the root this.signals, so each
source switch tears its proxies down in order rather than leaking a root-scoped
writer onto broadcast.video/audio.source.
The tile's active-state effect both wrote watch.muted and read the catalog (for
the speaker badge), so every catalog frame re-ran it and re-asserted muted=false
on the active tile, reverting the user's speaker-mute a moment after each click.
Split it into two effects: the active styling + muted policy now depends on
`active` alone, and the badge keeps its own catalog-reading effect.
Audio.Decoder is built once and its #discontinuity outlives the subscription, but
the container Consumer's rewind counter is per-consumer and a fresh subscription
restarts at zero. After any rewind bumped the decoder's count to >=1, the next
resubscribe delivered 0, which looked like a rewind and reset the Sync reference
shared with video. Re-anchor #discontinuity = 0 when the subscription is rebuilt.
Add a consumer test pinning that a fresh/resubscribed Consumer starts at zero.
…ransport rule

Remove the unused `safariVersion` export from @moq/hang (zero consumers repo-wide;
detectSafariVersion stays, still used by the worker-capture gate). This is a
breaking export removal, hence the dev base.

The Safari-detection rule is duplicated in @moq/hang's detectSafari and @moq/net's
pickTransport and cannot share code (@moq/hang depends on @moq/net, and pickTransport
is internal). Add cross-reference comments on both stating they must agree, and widen
the shared UA corpus in hacks.test.ts and transport.test.ts (iOS Firefox, an
uppercase UA, and a legacy Android stock-browser UA) so a drift between them fails a
test in each package.

Also add the missing `test` script to js/hang/package.json so hang's tests actually
run under `just js test` (they were silently skipped, which would have hidden the
drift guard).
Add min-width: 0 to the buffer-bar canvas so it can shrink below its intrinsic
width inside the flex row instead of overflowing it.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @fperex, your pull request is larger than the review limit of 150000 diff characters

@fperex fperex marked this pull request as draft July 11, 2026 01:52
fperex added 2 commits July 11, 2026 09:37
…lace

Muting previously kept the microphone capturing and only zeroed the encoder
gain, so the OS recording indicator stayed on while "muted". Now muting stops
audio capture per source: a microphone track is stopped outright (releasing
the OS device and its indicator), while screen/file audio detaches from the
encoder, since stopping a share track would kill the share and re-prompt.

The encoder pipeline survives the mute: a silent ConstantSourceNode keeps the
capture worklet emitting true silence on the same AudioContext, so the catalog
never drops or reshapes the audio track and watchers keep their audio subtree
instead of rebuilding it. Selecting a source while muted never touches the
microphone at all; unmuting re-acquires it.

Verified with a headless-Chrome fake-device run: zero getUserMedia calls while
muted, the mic track ends on every mute, the catalog stays shape-identical
across mute cycles, and a single AudioContext spans the whole cycle.
Cleanups from a full quality audit of the fork diff (duplication, dead
machinery, comment conventions, API hygiene). No behavior changes beyond
console log levels:

- Finish the isFirefox migration in watch/support (the publish twin already
  uses Util.Hacks.isFirefox); drop the local UA sniff.
- Replace the fork-added #closing flag in lite/connection with the
  isStreamAbort classification every other run-loop catch already uses.
- Remove the audio encoder's #codecMime computed signal and the manual
  format field-compare; Signal.set's deep-equality dedupe already covers
  both, and the ordering hazard the computed worked around disappears.
- Stop exporting configSuperseded from the @moq/watch video barrel; it is
  a test helper, and the test imports it directly.
- Drop a try/catch around a spec-infallible track.stop() and a stale-timer
  guard in the catalog retry arm that defends against an impossible race.
- Compress comments that restated the same invariant in multiple places
  (mute policy, bitrate-churn capture, isStreamAbort rationale, generation
  bump) and rewrite history-tense comments to describe the current code.
- Document the new public members (announcedGenerations, bytesEncoded,
  AudioBuffer.wait's resolve-on-abort contract); align README/doc pages
  with the unsupported-codec indicator and mute semantics.
- Use the branded Time.Milli constructor over a bare cast and match the
  file's import-extension style in video/polyfill.
@fperex fperex changed the title Safari hardware capture + connection reliability + audio soft-mute (fork batch) fix(js): Safari playback/publish support, connection reliability, and a capture-releasing mute Jul 11, 2026
@fperex fperex marked this pull request as ready for review July 11, 2026 14:47

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @fperex, your pull request is larger than the review limit of 150000 diff characters

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.

1 participant