feat(video): pause/resume video stream via JSON-RPC for client bandwidth savings#1455
Open
mcuelenaere wants to merge 5 commits into
Open
feat(video): pause/resume video stream via JSON-RPC for client bandwidth savings#1455mcuelenaere wants to merge 5 commits into
mcuelenaere wants to merge 5 commits into
Conversation
mcuelenaere
added a commit
to mcuelenaere/kvm
that referenced
this pull request
May 9, 2026
The pauseVideo handler stops the global encoder, but the videoPaused gate flag is per-session. During the 1s session-handover overlap (web.go / cloud.go schedule the old peer connection to close after a delay) the new session takes over currentSession with a fresh videoPaused=false and onFirstSessionConnected does not fire because activeSessions stays > 0. The new client's resumeVideo therefore no-ops and the encoder remains stopped, leaving the new session with no video. Restart the encoder at the handover site if the outgoing session left it paused. Reported by Cursor Bugbot on PR jetkvm#1455. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mcuelenaere
added a commit
to mcuelenaere/kvm
that referenced
this pull request
May 9, 2026
During the 1s session-handover overlap the previous session's data channel is still open. If the user switches focus away from the old tab in that window, its visibilitychange listener fires pauseVideo on the stale channel; the handler reads the global currentSession (now the new session) and stops the encoder, leaving the new client stuck paused with no automatic recovery while it remains visible. Drop pauseVideo and resumeVideo silently when they arrive from a session that isn't current. Reported by Cursor Bugbot on PR jetkvm#1455. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
54c1533 to
5780d7f
Compare
…eVideo Introduces acquireVideoStream / releaseVideoStream / videoStreamHasConsumers helpers in video.go that gate the native encoder's VideoStart / VideoStop and the HDMI sleep ticker on a set of named consumers. The encoder runs iff at least one consumer holds it; the 0→1 transition runs VideoStart so the first delivered frame is an IDR. Each WebRTC Session gets a stable id (uuid in newSession) and its own consumer key "webrtc:<id>". The slot is acquired when ICE reaches Connected and released when it reaches Closed. pauseVideo / resumeVideo JSON-RPC notifications toggle the calling session's own slot, handled inline in onRPCMessage so the dispatcher's session reference is in scope (the generic reflection-based dispatch doesn't pass *Session). This data model fixes by construction the two race classes Cursor Bugbot flagged on earlier drafts of this PR: - Handover-while-paused: the new session's own acquire is what starts the encoder; no special-case "restart if the outgoing session was paused" code at the handover site. - Stale-session pause: a pauseVideo from the soon-to-close session releases only its own slot, not the new session's; no need for a session != currentSession gate. The 1s handover overlap can briefly deliver frames to a paused session's track if the other session is keeping the encoder alive; bounded by the existing peer-connection close delay and acceptable for a feature aimed at sustained idle bandwidth saving. The refcount helpers are byte-identical to those in PR jetkvm#1447 (commit dec25e1), so that PR will merge cleanly on top. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5780d7f to
cc0832c
Compare
Mirrors the new server-side pauseVideo/resumeVideo JSON-RPC methods in the web frontend. When the user switches tabs or minimizes the browser, the encoder feed stops and outbound RTP drops to keepalive levels until the tab regains visibility. State is synced on mount so a reconnect into an already-hidden tab pauses immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cc0832c to
c0ab7ac
Compare
… in dispatcher
Extends RPCHandler with two new orthogonal flags:
- TakesSession: dispatcher injects the receiving *Session as the
handler's first reflected argument. Lets session-bound RPCs act on
the session that delivered the message rather than the global
currentSession.
- Synchronous: handler runs inline on the per-session rpcQueue pump
goroutine instead of in a fresh per-message goroutine. Preserves
dequeue order for ordering-sensitive RPCs.
Plumbs *Session through callRPCHandler / riskyCallRPCHandler and shifts
the reflected param index by one when TakesSession is set. Splits the
existing onRPCMessage into:
- onRPCMessage: parse + lookup + sync/async decision; runs on the pump.
- invokeRPCHandler: handler invocation + response write; runs either
inline (Synchronous) or in a goroutine (default).
The goroutine spawn previously living in the WebRTC pump
(webrtc.go:381) moves into onRPCMessage, where the JSON-RPC layer can
decide per handler. Existing handlers all keep zero-value defaults
(TakesSession: false, Synchronous: false), so their dispatch behaviour
is identical: today's "go onRPCMessage(...)" is now "go
invokeRPCHandler(...)" — one extra stack frame, otherwise the same.
No handler uses the new flags in this commit; that follows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…spatcher Replaces the inline switch in onRPCMessage with a pair of regular RPCHandler entries flagged TakesSession + Synchronous. The dispatcher runs them on the per-session rpcQueue pump goroutine — which is the single sequential consumer of the queue — so a rapid pause→resume pair can no longer be reordered by the Go scheduler. Previously each RPC was dispatched via "go onRPCMessage(...)", letting the scheduler interleave the two helpers so a release could land after the matching acquire and leave the encoder stopped while the tab was visible. Reported by Cursor Bugbot: jetkvm#1455 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit eeabe73. Configure here.
…ion close The ICE Closed handler used to close(rpcQueue) and then run releaseVideoStream immediately. The pump goroutine drains a closed channel's buffer asynchronously, so a buffered resumeVideo could land *after* the release ran. rpcResumeVideo on the dispatcher pump would re-acquire the consumer slot with no future code path that releases it — a permanent leak of the consumer key. The encoder could never stop and HDMI sleep could never activate until the device was restarted. Add a rpcQueueDone channel closed from the pump goroutine's defer. The ICE Closed handler now waits on it immediately after closing rpcQueue, before any downstream session-shutdown work. Source-order then guarantees that every buffered pause/resume runs before releaseVideoStream, and a buffered resumeVideo can no longer re-acquire after release. Reported by Cursor Bugbot: jetkvm#1455 (comment) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

Summary
Adds two JSON-RPC notifications —
pauseVideoandresumeVideo— that let a client release and re-acquire the video encoder on demand without renegotiating the WebRTC session. Outbound RTP drops from the 1–3 Mbps stream to keepalive levels (~50 B/s) while the encoder is stopped; HID input and every data channel stay live. The web frontend wires the toggle to the Page Visibility API; the native macOS client we're building consumes the same protocol.Why
The native macOS client wants to stop wasting WAN bandwidth when the user occludes its window. SDP renegotiation (
direction: .inactiveon the recv transceiver) was the obvious alternative, but JetKVM's signaling treats"offer"as "new session, kick the old one" — same payoff, much more code on both ends. The browser frontend gets the same bandwidth saving for free by reusing this RPC pair from the visibility listener.Design
Refcounted capture pipeline
The video encoder is owned by a named-consumer refcount in
video.go:The first acquirer starts the native encoder and pauses the HDMI sleep ticker; the last releaser stops the encoder and restarts the ticker. Both helpers are idempotent per consumer key.
Each WebRTC
Sessiongets a stable UUIDid(generated innewSession) and holds the consumer key"webrtc:<id>". The slot is acquired when ICE reachesConnectedand released when it reachesClosed.Generic session-aware, ordering-aware JSON-RPC dispatch
RPCHandlergains two orthogonal flags:TakesSession— the dispatcher injects the receiving*Sessionas the handler's first reflected argument. Lets session-bound RPCs act on the session that delivered the message rather than on the globalcurrentSession.Synchronous— the handler runs inline on the per-sessionrpcQueuepump goroutine instead of in a fresh per-message goroutine. Preserves dequeue order for ordering-sensitive RPCs.onRPCMessageis now split into a dispatcher (parse + lookup + sync/async decision) and aninvokeRPCHandlerhelper. The goroutine-per-message spawn previously living in the WebRTC pump (the oldwebrtc.go:381go onRPCMessage(...), which the TODO comment already flagged as a wart) is now the dispatcher's responsibility — async by default for slow handlers (mountWithHTTP,tryUpdateComponents, …), inline for the newSynchronousones. Every existing handler keeps zero-value defaults, so dispatch behaviour is unchanged for them.pauseVideoandresumeVideoregister as regularrpcHandlersentries withTakesSession: true, Synchronous: true. They take*Sessionand callreleaseVideoStream(s.videoConsumerKey())/acquireVideoStream(s.videoConsumerKey())respectively.Why this fixes the three race classes Cursor Bugbot flagged
acquireVideoStreamis the 0→1 refcount transition that callsVideoStartand emits the IDR — no special-case "restart if outgoing was paused" code at the handover site.TakesSessioninjection means the handler acts on the session that delivered the message; apauseVideofrom the soon-to-close session releases only its own consumer key. The incoming session's key keeps the encoder running. Nosession != currentSessiongate needed.Synchronous: trueruns both handlers inline on the rpcQueue pump goroutine — the single sequential consumer of the per-session queue. The Go scheduler can no longer interleave a tight pause→resume pair to leave the encoder stopped while the tab is visible.Commit history
a31c82ac0ab7ace7bc932eeabe73Commit 3 is a pure infrastructure change — no caller uses the new flags yet, so dispatch behaviour is preserved. Commit 4 is a tiny semantic change that adopts the new mechanism for pause/resume.
Notes
dec25e1— that PR will merge cleanly on top of this one. Whichever lands first, the other rebases trivially.time.Sleep(1 * time.Second)beforepeerConn.Close()inweb.go/cloud.go. Acceptable for a feature aimed at sustained idle bandwidth saving (minutes/hours).Constraints honoured
otherSessionConnectedor other side effects frompauseVideo/resumeVideothemselves.Checklist
make test_e2elocally and passedCloses #<issue-number>)go vet ./...clean inside buildkit container; oxlint+oxfmt ran via pre-commit hook on the frontend)acquire/release,RPCHandler.TakesSessionandSynchronousdoc-comments)Test plan
End-to-end (requires hardware):
{"jsonrpc": "2.0", "method": "pauseVideo"}on therpcdata channel.tcpdump/iftop/ pion outbound-rtp stats.{"jsonrpc": "2.0", "method": "resumeVideo"}.For the browser path: switch tabs / minimize the window with the JetKVM tab open and verify the bandwidth drop, then return and verify clean resume.
Race regression scenarios:
VideoStart→ IDR. B should see video within ~100 ms.pauseVideoreleases only A's slot (viaTakesSessioninjection); B's slot keeps the encoder running.pauseVideo/resumeVideomessages and assert the final encoder state matches the last message. Before commiteeabe73this fails non-deterministically; after the fix the dispatcher runs both inline on the pump and the test passes every time. Spot-check that slow handlers (mountWithHTTP) still get goroutine-per-message dispatch so they can't head-of-line block the queue.🤖 Generated with Claude Code
Note
Medium Risk
Touches WebRTC session lifecycle, JSON-RPC dispatch concurrency, and native video start/stop behavior; mistakes could cause stuck video, leaked consumers, or ordering races, but changes are localized and add idempotent guards.
Overview
Adds
pauseVideo/resumeVideoJSON-RPC notifications that let clients stop/restart the native video encoder without tearing down the WebRTC session, and wires the web UI to automatically toggle this via the Page Visibility API.Refactors JSON-RPC dispatch to support session-injected handlers (
TakesSession) and ordered, inline execution for select RPCs (Synchronous), while keeping other handlers async to avoid queue head-of-line blocking.Introduces a named-consumer refcount for the capture pipeline (
acquireVideoStream/releaseVideoStream) and updates WebRTC session connect/close to acquire/release a per-session consumer key, including waiting for the RPC queue to drain before teardown to avoid pause/resume races.Reviewed by Cursor Bugbot for commit 9638825. Bugbot is set up for automated code reviews on this repo. Configure here.