fix(desktop): stop the screen-share host freezing the whole machine - #92
Merged
Conversation
…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
ThreatCrush Security Scan72 finding(s) HIGH/CRITICAL: 13 | MEDIUM: 41 | LOW: 18
…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
marked this pull request as ready for review
August 29, 2026 16:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 } })— andidealis 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.
maxongetDisplayMedia, and the mandatory maxima on thedesktopCapturerpath.The compositor drew at refresh rate, not capture rate
captureStream(30)samples 30×/sec and discards the rest, but the loop re-armedrequestAnimationFrameevery callback — 60Hz, or 144Hz on a high-refresh panel — andbackgroundThrottling: 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
writeRecordingChunkandwriteStreamChunkwaited 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'sondataavailable, 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(notMemFree, 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.shLogs memory, load and per-process RSS with an
fsynceach 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
fitWithinoriginally 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 --noEmitclean, eslint clean, prettier clean, 645 desktop tests across 61 files pass (26 new).Important
Merging does not ship this.
desktop-release.ymlonly triggers on av*tag, so it reaches the affected machine only once a release is cut.🤖 Generated with Claude Code
https://claude.ai/code/session_01SuY5Bj41aCoqroCzVGZBPM