Skip to content

feat(video): pause/resume video stream via JSON-RPC for client bandwidth savings#1455

Open
mcuelenaere wants to merge 5 commits into
jetkvm:devfrom
mcuelenaere:claude/eloquent-feynman-189c2d
Open

feat(video): pause/resume video stream via JSON-RPC for client bandwidth savings#1455
mcuelenaere wants to merge 5 commits into
jetkvm:devfrom
mcuelenaere:claude/eloquent-feynman-189c2d

Conversation

@mcuelenaere
Copy link
Copy Markdown
Contributor

@mcuelenaere mcuelenaere commented May 8, 2026

Summary

Adds two JSON-RPC notifications — pauseVideo and resumeVideo — 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: .inactive on 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:

acquireVideoStream(consumer string)
releaseVideoStream(consumer string)
videoStreamHasConsumers() bool

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 Session gets a stable UUID id (generated in newSession) and holds the consumer key "webrtc:<id>". The slot is acquired when ICE reaches Connected and released when it reaches Closed.

Generic session-aware, ordering-aware JSON-RPC dispatch

RPCHandler gains two orthogonal flags:

  • TakesSession — the 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 on the global currentSession.
  • Synchronous — the 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.

onRPCMessage is now split into a dispatcher (parse + lookup + sync/async decision) and an invokeRPCHandler helper. The goroutine-per-message spawn previously living in the WebRTC pump (the old webrtc.go:381 go 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 new Synchronous ones. Every existing handler keeps zero-value defaults, so dispatch behaviour is unchanged for them.

pauseVideo and resumeVideo register as regular rpcHandlers entries with TakesSession: true, Synchronous: true. They take *Session and call releaseVideoStream(s.videoConsumerKey()) / acquireVideoStream(s.videoConsumerKey()) respectively.

Why this fixes the three race classes Cursor Bugbot flagged

  1. Handover-while-paused. The new session's own acquireVideoStream is the 0→1 refcount transition that calls VideoStart and emits the IDR — no special-case "restart if outgoing was paused" code at the handover site.
  2. Stale-session pause. TakesSession injection means the handler acts on the session that delivered the message; a pauseVideo from the soon-to-close session releases only its own consumer key. The incoming session's key keeps the encoder running. No session != currentSession gate needed.
  3. Goroutine reordering of pause/resume pairs. Synchronous: true runs 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

# SHA Title
1 a31c82a feat(video): refcount the capture pipeline; expose pauseVideo / resumeVideo
2 c0ab7ac feat(ui): pause video stream when tab is hidden via Page Visibility API
3 e7bc932 refactor(jsonrpc): support session injection and synchronous handlers in dispatcher
4 eeabe73 fix(video): make pause/resume race-free by routing through the new dispatcher

Commit 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

  • The refcount helpers in commit 1 are byte-identical to those in PR #1447 commit dec25e1 — that PR will merge cleanly on top of this one. Whichever lands first, the other rebases trivially.
  • Documented limitation: during the 1s handover overlap, a paused session's track may briefly receive frames if the other session is keeping the encoder alive. Bounded by the existing time.Sleep(1 * time.Second) before peerConn.Close() in web.go / cloud.go. Acceptable for a feature aimed at sustained idle bandwidth saving (minutes/hours).

Constraints honoured

  • Connect flow unchanged; new sessions start with their slot acquired.
  • Pion video track and data channels stay alive throughout pause; only the encoder is stopped.
  • No otherSessionConnected or other side effects from pauseVideo / resumeVideo themselves.
  • Idempotent under rapid re-fire (60 Hz pause/resume bursts from window-state animations are safe).

Checklist

  • Ran make test_e2e locally and passed
  • Linked to issue(s) above by issue number (e.g. Closes #<issue-number>)
  • One problem per PR (no unrelated changes)
  • Lints pass; CI green (go vet ./... clean inside buildkit container; oxlint+oxfmt ran via pre-commit hook on the frontend)
  • Tricky parts are commented in code (refcount semantics on acquire/release, RPCHandler.TakesSession and Synchronous doc-comments)

Test plan

End-to-end (requires hardware):

  1. Connect with the JetKVM web frontend or any WebRTC client. Confirm video flows.
  2. Send {"jsonrpc": "2.0", "method": "pauseVideo"} on the rpc data channel.
  3. RTP traffic to the client drops to keepalive levels (~few hundred B/s). Verify with tcpdump / iftop / pion outbound-rtp stats.
  4. Send {"jsonrpc": "2.0", "method": "resumeVideo"}.
  5. Video resumes within ~100 ms with no decode artifacts (no green block patches, no smear).
  6. Repeat pause/resume 10 cycles in 5 seconds. No hangs, no leaked goroutines, no stuck states.

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:

  1. Handover-while-paused. Open tab A, hide it (A pauses → A's slot released → encoder off). Open tab B (visible). B's acquire is a 0→1 refcount transition → VideoStart → IDR. B should see video within ~100 ms.
  2. Stale-tab pause during handover. Tab A active and visible. Open tab B. While A is still in its 1s grace, hide A (focus B). A's pauseVideo releases only A's slot (via TakesSession injection); B's slot keeps the encoder running.
  3. Disconnect-while-paused. Pause then close the tab. Reconnect a fresh session — video flows immediately on connect (new session's acquire is the 0→1 transition).
  4. Pause/resume goroutine reordering. Programmatically send ~100 back-to-back alternating pauseVideo/resumeVideo messages and assert the final encoder state matches the last message. Before commit eeabe73 this 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/resumeVideo JSON-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.

Comment thread video.go
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>
Comment thread video.go
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>
Comment thread web.go Outdated
Comment thread jsonrpc.go
@mcuelenaere mcuelenaere marked this pull request as draft May 9, 2026 00:39
@mcuelenaere mcuelenaere force-pushed the claude/eloquent-feynman-189c2d branch from 54c1533 to 5780d7f Compare May 11, 2026 15:57
Comment thread webrtc.go Outdated
…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>
@mcuelenaere mcuelenaere force-pushed the claude/eloquent-feynman-189c2d branch from 5780d7f to cc0832c Compare May 12, 2026 20:31
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>
@mcuelenaere mcuelenaere force-pushed the claude/eloquent-feynman-189c2d branch from cc0832c to c0ab7ac Compare May 12, 2026 20:39
@mcuelenaere mcuelenaere marked this pull request as ready for review May 12, 2026 20:39
Comment thread jsonrpc.go Outdated
mcuelenaere and others added 2 commits May 12, 2026 23:27
… 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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread webrtc.go
…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>
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