Skip to content

fix(desktop): stop the screen-share host freezing the whole machine - #92

Merged
ralyodio merged 2 commits into
masterfrom
worktree-freeze-hardening
Aug 29, 2026
Merged

fix(desktop): stop the screen-share host freezing the whole machine#92
ralyodio merged 2 commits into
masterfrom
worktree-freeze-hardening

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Hosting a screen share on Kubuntu locks the entire machine up ~10 minutes in, hard enough to need a power cycle. Seen at least twice, about a week apart.

The two plausible causes — memory exhaustion and a GPU/driver hang — both come down to the host consuming resources with no ceiling. So this puts ceilings on it, and a floor under what it is allowed to do to the machine.

The quality setting never actually bound anything

It was applied in exactly one place — applyConstraints({ width: { ideal } }) — and ideal is a hint the source may ignore. Chromium's desktop capturer does. A 4K monitor kept producing a 3840×2160 track no matter what you picked, and everything downstream sized itself from that.

The compositor canvas was the worst of it: 8.3M pixels per frame, composited to produce output that was going to be encoded at 1080p anyway.

  • The bound now lives where it binds: max on getDisplayMedia, and the mandatory maxima on the desktopCapturer path.
  • The compositor fits its canvas to the same budget, preserving the source's aspect ratio.
  • Capture asks for 30fps instead of up to 60. Screen content is static, and every extra frame is another full-resolution encode on a machine already compositing, recording and feeding ffmpeg.

The compositor drew at refresh rate, not capture rate

captureStream(30) samples 30×/sec and discards the rest, but the loop re-armed requestAnimationFrame every callback — 60Hz, or 144Hz on a high-refresh panel — and backgroundThrottling: false (deliberate; the host window is backgrounded for the whole share) meant nothing damped it. Gated to the sample rate. Output is unchanged.

Combined with the resolution cap, a 4K/144Hz host goes from ~4.8 GB/s of canvas traffic to roughly a tenth of that.

Both backpressure waits could hang forever

writeRecordingChunk and writeStreamChunk waited only on 'drain'. A write stream that errors or closes while backpressured never emits it; neither does a dead ffmpeg's stdin — the pipe just closes. The renderer awaits these inside MediaRecorder's ondataavailable, which keeps firing every 250ms, so one stalled wait retains every later chunk in both processes — 1–2.5 MB/s, climbing until the box is out of memory. Both now settle on the terminal states.

Resource guard: stop the share, not the machine

The backstop for whatever is still unaccounted for. Watches MemAvailable (not MemFree, which excludes reclaimable cache and would fire constantly); when the machine is genuinely short it stops the recording, stops any egress, and tells the renderer to drop capture, with a banner explaining why. Each level fires once per episode and re-arms only after memory recovers — the critical handler ends a session, so repeating it would loop.

Stopping the recording first is deliberate. Closing the write stream is what finalises the file. A machine that seizes never runs recording:stop, which is why sessions that ended in a freeze never appeared in the channel: the WebM was left without its metadata. A guarded stop keeps the recording; a power cut does not.

scripts/freeze-watchdog.sh

Logs memory, load and per-process RSS with an fsync each interval, so if a freeze still happens there is a last line on disk instead of nothing. Its header explains how to read the tail.

A bug the tests caught

fitWithin originally rounded to a multiple of 16, per the presets' claim to be macroblock-aligned. That turns a 1080 bound into 1088 — overshooting the very limit being applied. It now floors to even, which is what 4:2:0 chroma actually requires; the presets were never 16-aligned anyway (1080 is not a multiple of 16).

Verification

tsc --noEmit clean, eslint clean, prettier clean, 645 desktop tests across 61 files pass (26 new).

Important

Merging does not ship this. desktop-release.yml only triggers on a v* tag, so it reaches the affected machine only once a release is cut.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SuY5Bj41aCoqroCzVGZBPM

…share

Triaging a Kubuntu host that locks the whole machine up roughly ten minutes
into a screen share, hard enough to need a power cycle. Nothing here is
confirmed as the cause yet — the evidence dies with the power-off — but all
three are real defects on exactly that path, and two of them are unbounded.

Compositor drew at the display's refresh rate, not the capture rate.
captureStream(30) samples the canvas 30 times a second and discards whatever
was drawn in between, but the loop re-armed requestAnimationFrame every
callback: 60Hz, or 144Hz on a high-refresh monitor. backgroundThrottling is
deliberately off (the host window is backgrounded for the whole share by
design), so nothing ever damped it. The canvas is sized to the screen track's
native resolution, so on 4K that is a 33MB clear+draw several GB/s against the
same GPU the desktop composites with — 2-5x of it thrown away. Gate the draw to
the sample rate; the published and recorded output is unchanged. Only runs with
the camera bubble on, which fits a freeze that happens some sessions and not
others.

Both backpressure waits could never settle. A write stream that errors or
closes while backpressured never emits 'drain', and neither does an ffmpeg
stdin whose process just died — the pipe closes instead. The renderer awaits
these calls inside MediaRecorder's ondataavailable, which keeps firing every
250ms regardless, so a single stalled wait leaves every later chunk retained in
both processes: 1MB/s at 8Mbps, 2.5MB/s at 4K, growing until the box is out of
memory. Settle on the terminal states too.

Also pause the compositor's hidden video elements before dropping srcObject,
which otherwise leaves them decoding until GC.

scripts/freeze-watchdog.sh records memory, load and per-process RSS with an
fsync each interval, so the next freeze leaves a last line on disk instead of
taking the evidence down with it.

Typecheck, lint and prettier clean; 619 desktop tests across 59 files pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuY5Bj41aCoqroCzVGZBPM
@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.

…ine does

Follow-up to the frame/buffer fixes: put an actual ceiling on what a host
consumes, and a floor under what it is allowed to do to the machine.

The quality setting never bound anything. It was applied in exactly one place,
`applyConstraints({ width: { ideal } })`, and `ideal` is a hint the source may
ignore — Chromium's desktop capturer does. A 4K monitor kept producing a
3840x2160 track no matter what the user picked, and everything downstream sized
itself from that: the compositor canvas allocated 8.3M pixels a frame to
composite output that was going to be encoded at 1080p anyway. The bound now
lives where it binds — `max` on getDisplayMedia, the mandatory maxima on the
desktopCapturer path — and the compositor fits the canvas to the same budget,
keeping the source's aspect ratio. Capture also asks for 30fps instead of up to
60: screen content is static, and every extra frame is another full-resolution
encode on a machine already compositing, recording and feeding ffmpeg.

`fitWithin` floors to even rather than rounding to a multiple of 16. Rounding
turned a 1080 bound into 1088 and overshot the limit being applied; a test
caught it. Even is what 4:2:0 chroma actually needs, and the presets were never
16-aligned anyway (1080 is not a multiple of 16).

The resource guard is the backstop for whatever is still unaccounted for. It
watches MemAvailable — not MemFree, which excludes reclaimable cache and would
fire constantly — and when the machine is genuinely short it stops the
recording, stops any egress and tells the renderer to drop capture. Each level
fires once per episode and re-arms only after memory recovers, because the
critical handler ends a session and repeating it would loop.

Stopping the recording first is deliberate: closing the write stream is what
finalises the file. A machine that seizes never runs `recording:stop`, which is
why sessions that ended in a freeze never showed up — the WebM was left without
its metadata. A guarded stop keeps the recording; a power cut does not.

Typecheck, lint and prettier clean; 645 desktop tests across 61 files pass,
26 of them new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuY5Bj41aCoqroCzVGZBPM
@ralyodio ralyodio changed the title fix(desktop): stop the host burning frames and buffers during a long share fix(desktop): stop the screen-share host freezing the whole machine Aug 29, 2026
@ralyodio
ralyodio marked this pull request as ready for review August 29, 2026 16:04
@ralyodio
ralyodio merged commit 76a295e into master Aug 29, 2026
13 checks passed
@ralyodio
ralyodio deleted the worktree-freeze-hardening branch August 29, 2026 16:07
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