Skip to content

Pace a headless output and the stream to the client's refresh rate - #117

Open
londospark wants to merge 4 commits into
omacom:mainfrom
londospark:refresh-rate
Open

londospark wants to merge 4 commits into
omacom:mainfrom
londospark:refresh-rate

Conversation

@londospark

@londospark londospark commented Nov 1, 2026 •

Copy link
Copy Markdown

Builds on #116. This branch contains its commits, so review or merge that first. The changes in this PR are the last two commits, f249b9a and a0e32d6 (review fixes).

Fixes #114

What happens

A headless session runs at 60 Hz and at most 60 fps whatever the client's display runs at:

  • Resize carries no refresh rate.
  • Headless outputs are moded at a hard-coded 60 Hz.
  • MAX_FPS = 60 caps every session.

The change

  • Protocol: ClientMsg::Resize gains refresh_hz: Option<u32> at #[n(3)], following the protocol rules. An older client decodes as None, and an older server ignores the extra field. A test covers the older client's Resize.
  • Client: sends the refresh rate of the Hyprland monitor holding its window. It finds that monitor by pid through a new hypr_ipc::Instance::clients. It checks once a second and re-sends Resize when the window moves to a monitor with a different refresh.
  • Server, headless outputs: moded at the client's refresh in resize. A refresh-only change re-paces without rebuilding the encoder.
  • Server, frame-rate ceiling: the output's refresh replaces MAX_FPS, for mirrored outputs too: a 165 Hz monitor streams at up to 165 fps. MAX_FPS_LIMIT = 480 only bounds nonsense values. The ladder from Pick the frame rate from the link, never reduce resolution #116 picks the frame rate below the ceiling from the link.
  • --max-fps N (client and server) caps the output mode and the stream.
  • Client frame polling: decoded frames are polled every 2 ms instead of 8, so a stream above about 120 fps isn't held to the poll rate.
  • gliff-probe stream-bench --refresh sends the new field.

On Omarchy and other Lua configs, the mode change needs #110 (Hyprland 0.57 answers keyword with unknown request). Without #110 the refresh still reaches the session's ceiling, but the output keeps its mode.

Testing

  • scripts/check.sh passes.
  • Tested with an RTX 5080 server on Hyprland 0.57.0 (Omarchy, with Resize a headless output on Hyprland 0.57 with a Lua config #110's fix) and a laptop client on a 2560x1600@240 panel:
    • gliff-probe stream-bench --refresh 240 moves the headless output from @60 to @240, and fps_cap in StreamConfig follows.
    • In daily use over Wi-Fi, the stream settles around 60 fps at sharp quality, and the cap rises on a faster link.
  • Not exercised: a client window moving to a 60 Hz monitor, and scripts/e2e.sh.

🤖 Generated with Claude Code

The ladder judged a rung affordable at 0.03 bits per pixel per stream, so
a 2560x1600 Dual420 stream at 15 Mbit/s ran at 60 fps with visibly blocky
text; sharp desktop text wants about 0.1. The rungs were also coarse
(60, 30, 15, 10, 5), and the bottom two halved the resolution.

The ladder now chooses a rung { aux, fps } directly: the highest frame
rate the measured rate pays for at --quality-bpp (0.08 by default),
keeping the 4:4:4 chroma stream while that is at least 30 fps, then
Single420, down to 5 fps. The stream is always the window's size.
Hysteresis is unchanged, plus no move smaller than 15% of the frame rate.
The bitrate ceiling is raised from 80 to 250 Mbit/s (still bounded by
pixels x streams x fps x 0.1) so a large wired stream can pay for full
quality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with the remaining direct-connection cap issue non-blocking.

Findings

  1. P2 Direct connections ignore the cap ▶

Summary

The PR follows the client's monitor refresh for headless outputs and uses the output refresh as the stream ceiling.

  • Remote sessions follow the client window’s monitor refresh rate.
  • Streams keep their full size and adjust frame rate to the link.

Diagram

sequenceDiagram
    participant H as Local Hyprland
    participant W as Refresh worker
    participant C as Client window
    participant S as Server
    participant O as Headless output
    W->>H: Read window monitor and refresh
    W-->>C: Latest refresh
    C->>S: Resize with capped refresh
    S->>O: Apply size, scale, and refresh
    S->>S: Update frame pacing and bitrate bounds
    S-->>C: StreamConfig
Loading

Reviews (2) · Last reviewed commit: "Address review: caps, stale bounds, bloc..." · Reviewed by Greptile

Comment thread crates/gliff-server/src/session.rs Resolved
Comment thread crates/gliff-server/src/session.rs Resolved
Comment thread crates/gliff-server/src/session.rs Resolved
Comment thread crates/gliff/src/main.rs Resolved
Comment thread crates/gliff-probe/src/main.rs Resolved
Comment thread crates/gliff/src/main.rs
Comment on lines +277 to +279
if cli.max_fps > 0 {
args.push("--max-fps".into());
args.push(cli.max_fps.to_string());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Direct connections ignore the cap

The client's --max-fps has no effect with --connect. Its only use is adding arguments to an SSH-launched server, while direct connections still send the uncapped monitor refresh. Users can receive a higher frame rate than they requested. Apply the cap for direct connections too, or reject this option combination instead of silently ignoring it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in a0e32d6: the client caps the refresh it sends at --max-fps, so the cap also holds with --connect. Over SSH it is still passed to the server as well, where it also caps a mirrored output.

londospark and others added 3 commits November 1, 2026 23:14
…return

After the ladder dropped to Single420, RateController bounded the target
by one stream (6 x pixels at 60 fps), but restoring Dual420 at 30 fps
with 1.25x headroom needs 7.06 x pixels, so a recovered link never got
full chroma back. Bound it by the most streams the session has used;
the per-stream bitrate still divides by the active count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A headless session ran at 60 Hz and at most 60 fps whatever the client's
display: Resize carried only size and scale, headless outputs were moded
at a hard-coded 60 Hz, and MAX_FPS = 60 capped every session.

- Resize gains refresh_hz (Option, so older clients decode as None): the
  refresh of the Hyprland monitor holding the client's window, re-sent
  when the window moves to another monitor.
- The server modes a headless output at that refresh and uses the
  output's refresh as the session's frame-rate ceiling, for mirrored
  outputs too (a 165 Hz monitor streams at up to 165 fps). The ladder
  picks the frame rate below it from the link.
- --max-fps (client and server) caps the output mode and the stream.
- The client polls decoded frames every 2 ms instead of 8, so a stream
  above ~120 fps is not held to the poll rate.
- gliff-probe stream-bench --refresh sends the new field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- --max-fps below 5 no longer panics the sustainable-fps clamp.
- A refresh-only resize rebounds the rate controller to the new
  ceiling (apply_stream_params reconfigures it on the no-rebuild path).
- A Resize without refresh_hz keeps the current refresh but still
  applies --max-fps, so the cap holds for older clients.
- The client reads its monitor's refresh on a worker thread; the GTK
  thread only takes the result. --max-fps caps the refresh it sends, so
  it also holds with --connect.
- gliff-probe stream-bench --refresh works without --width/--height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Nov 1, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

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.

A headless session is capped at 60 Hz and 60 fps, whatever the client's display runs at

1 participant