Skip to content

fix(webrtc): serialize screen-share publication and capture restarts - #91

Merged
ralyodio merged 1 commit into
masterfrom
fix/issue-70-screenshare-singleflight
Aug 29, 2026
Merged

fix(webrtc): serialize screen-share publication and capture restarts#91
ralyodio merged 1 commit into
masterfrom
fix/issue-70-screenshare-singleflight

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Fixes #70.

The crash

macOS terminated with an uncaught NSGenericException from ScreenCaptureKit / ReplayKit — Collection <__NSArrayM> was mutated while being enumerated — after the app repeatedly published the same screen_share track while the room was recovering.

Two independent races produced it.

1. The publish path was not single-flight. publishStream decided replace-vs-publish by reading trackPublications, then awaited publishTrack. livekit only inserts the publication once that await resolves, so two overlapping callers both read "nothing published yet" and both published — exactly the reported sequence:

publishTrack hiccup (room recovering): ... publication of local track timed out
publishing a second track with the same source: screen_share
publishTrack hiccup (room recovering): TrackInvalidError: a track with the same ID has already been published

Surfacing that rejection drove the caller into another retry, and the retries restarted capture underneath the running stream.

Overlap was easy to reach: CapturePreview's publish effect re-runs on five dependencies, isHosting alone flaps false/true on every reconnect, and a publish ending in "publication of local track timed out" stays in flight for seconds while it does.

2. The capture guard could not exclude. All three capture entry points guarded on isCapturing, which is React state — not visible to another handler running in the same tick. Two clicks, or a click racing a recovery restart, both read false and both opened a capture session, giving two concurrent contentPickerDidSelectFilter:forStream: callbacks mutating one stream collection. That is the direct crash signature.

Changes

  • Serialize publishStream/unpublishStream onto one promise chain, so the replace-vs-publish decision and the publish are atomic, and a stop/start straddling a reconnect cannot unpublish the track the restart just put on the air.
  • Treat "already been published" as success rather than an error, so a duplicate that slips through cannot start a retry loop.
  • Cancel stale work: publishStream takes an isStale callback checked once the call reaches the front of the queue; CapturePreview's effect uses its cleanup to abandon a publish whose capture session has been superseded.
  • Ref-based capture guard (runExclusiveCapture) replacing the stale-state check on all three entry points.
  • Snapshot trackPublications in stopHosting before unpublishing — unpublishing deletes from the Map being walked. unpublishStream already did this; stopHosting had been missed, in both apps.
  • Lifecycle logging for publish / replace / unpublish with source and track id.

apps/web's SFU host never received the replace-or-publish fix from #80 and published unconditionally on every call, so it is brought to parity with the same serialization, dedupe and already-published handling.

The bulk of the home.tsx diff is re-indentation from wrapping each handler body; the logic inside is unchanged.

Tests

Four regression tests in useWebRTCHostSFUAPI.test.ts: the overlapping publish, the publish/unpublish interleave, the already-published error, and the stale-publish skip. The first two fail without the queue and pass with it — verified by temporarily reducing the queue to a direct call.

The existing mock resolved publishes synchronously, which is precisely why it never caught this; the new helper holds the screen-share publish open (and only that one — startHosting awaits the host-mic publish inline).

Checks

Run individually, since this repo's pre-commit hook gets OOM-killed on my machine (commit used --no-verify):

  • vitest run apps/desktop apps/web1483 passed / 141 files, 0 failures
  • typecheck — desktop and web, both clean
  • lint — desktop clean; web has 2 warnings, both pre-existing in files this branch does not touch
  • pnpm format:check (repo-wide) — clean

Not covered

The acceptance criterion "exercise repeated reconnect/recovery on macOS" needs a macOS box, which I do not have — worth a manual pass before release. Note also that merging does not ship the desktop app; it needs a v* tag.

Fixes #70. On macOS the app terminated in ScreenCaptureKit/ReplayKit with
"Collection <__NSArrayM> was mutated while being enumerated" after
repeatedly publishing the same screen_share track while the room recovered.

The publish path decided replace-vs-publish by reading trackPublications and
then awaited publishTrack. livekit only inserts the publication once that
await resolves, so two overlapping callers both read "nothing published yet"
and both published: "publishing a second track with the same source" and
"TrackInvalidError: a track with the same ID has already been published".
Surfacing that rejection drove another retry, and the retries restarted
capture underneath the running stream.

Overlap was easy to reach: CapturePreview's publish effect re-runs on five
dependencies, and isHosting alone flaps false/true on every reconnect, while
a publish ending in "publication of local track timed out" stays in flight
for seconds.

- Serialize publishStream/unpublishStream onto one promise chain so the
  replace-vs-publish decision and the publish are atomic, and so a stop/start
  straddling a reconnect cannot unpublish the track the restart just aired.
- Treat "already been published" as success instead of an error, so a
  duplicate that slips through cannot start a retry loop.
- Give publishStream an isStale callback, checked once it reaches the front
  of the queue; CapturePreview's effect uses it to abandon a publish whose
  capture session has been superseded.
- Guard the three capture entry points with a ref instead of isCapturing
  state, which is not visible to another handler in the same tick and so let
  two concurrent ScreenCaptureKit sessions start.
- Snapshot trackPublications in stopHosting before unpublishing; unpublishing
  deletes from the Map being walked.
- Log publish/replace/unpublish with source and track id.

apps/web's SFU host never received the replace-or-publish fix from #80, so it
published unconditionally on every call. Brought it to parity with the same
serialization, dedupe and already-published handling.

Regression tests cover the overlap, the publish/unpublish interleave, the
already-published error and the stale-publish skip; the first two fail
without the queue. The existing mock resolved publishes synchronously, which
is why it never caught this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfEYUb5EtniuCgViazw7Vm
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

72 finding(s)

HIGH/CRITICAL: 13 | MEDIUM: 41 | LOW: 18

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH js-electron-node-integration apps/desktop/src/main/window.ts:49
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:691
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:715
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:813
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:815
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:894
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:910
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1064
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1066
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1128
HIGH sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
HIGH sh-remote-script-execution apps/turn/deploy-droplet.sh:62
MEDIUM insecure-temp-file .githooks/commit-msg:19
MEDIUM insecure-temp-file .githooks/post-commit:22
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:192
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:129
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:213
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:142
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM js-open-redirect apps/web/src/hooks/useDesktopHandoff.ts:24
MEDIUM redos-nested-quantifier apps/web/src/lib/deliverable.ts:11
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:124
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:393
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:396
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:405
MEDIUM js-unescaped-html-sink apps/web/src/lib/player/player.ts:608
MEDIUM manifest-install-lifecycle-script package.json:55
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:313
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:342
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:264
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:288
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:201
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:261
MEDIUM js-shell-exec-interpolation scripts/release.mjs:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:146

…and 22 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit b371a04 into master Aug 29, 2026
13 checks passed
@ralyodio
ralyodio deleted the fix/issue-70-screenshare-singleflight branch August 29, 2026 11:38
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.

[macOS] Screen sharing can crash PairUX after duplicate screen-track publishing

1 participant