Skip to content

fix(client): make the event loop un-parkable and pace the headless render loop - #4362

Closed
vietairs wants to merge 1461 commits into
herdrdev:masterfrom
vietairs:macos-anr-mitigation
Closed

vietairs wants to merge 1461 commits into
herdrdev:masterfrom
vietairs:macos-anr-mitigation

Conversation

@vietairs

Copy link
Copy Markdown

Why

On macOS 27.2 beta the herdr client hangs the host terminal (cmux + Terminal.app) with a
"Not Responding" dialog. Root cause (proven from hang report 17314: 2.19 h unresponsive, all 8
threads parked): the client event loop parked in its tokio::select! with a far timer
deadline + silent channels
, and should_quit was polled only at the loop head — so Ctrl-C
could neither wake nor exit it
. A secondary pressure source was the fixed 16 ms render cadence.

What changed (7 files, +182/−12)

1. Quit-wake arm — the loop can no longer park forever

  • New ClientLoopEvent::Quit; the ctrlc termination handler (SIGINT/SIGTERM/SIGHUP) now
    should_quit.store(true, Release) and quit_notify.notify_one().
  • quit_notify.notified() is a first arm of the select on both the unix (biased) and windows
    paths. A tokio::Notify permit is retained across iterations, so a termination signal is
    never lost even if it lands between loop passes. The Quit arm re-stores the flag and lets the
    existing shutdown path run — no new teardown path.

2. Timer clamp — bounded re-wake

  • ClientLoopTimer::deadline() clamps every requested delay to MAX_LOOP_TIMER_DELAY (250 ms)
    before the sticky-min. Any far/stale deadline re-wakes the loop (quit-flag check + health
    tick) at least every 250 ms. Normal delays are unaffected; an earlier sticky deadline still wins.

3. Local-endpoint heartbeat

  • Local shell connections now participate in the existing ping/pong health probe: a
    silent-but-alive local server surfaces as a 10 s timeout → clean ConnectionLost instead of an
    undetectable wedge.
  • Local terminal-attach connections (no shell surface) stay exempt — they cannot false-expire.
    The server already echoes pongs for every probed connection (verified end-to-end).

4. ui.render_interval_ms

  • New config key (default 16, clamped to ≥ 1) that is the single source of the
    render/presentation pacing interval previously hard-coded as MIN_RENDER_INTERVAL. Bounds the
    headless render loop's cadence without a rebuild; reloadable live via [ui].

Scope notes

  • Deferred (out of the minimal ANR closure, logged): I2 client-side timeout on terminal-open
    await; I6 kitty-graphics flush pacing; I7 server-side dead-client reaper (the server still can't
    detect a silent client); I8 ops tooling.
  • The render_interval_ms knob paces the headless (server) render loop specifically; the
    client-side TUI shell render path is paced by the 250 ms timer clamp instead.
  • SIGHUP now runs the full quit path (intended — a terminal hangup should exit a TTY-attached
    client; documented in-code).

Testing

  • cargo check clean (0 new warnings).
  • Focused tests 130/130 pass across the four changed subsystems (client::timer,
    client::endpoint, config::model, app::runtime); new tests pin the clamp, the local
    probe/exempt behavior, and the render-interval parse/clamp/reload.
  • Untouched-module failures are pre-existing flakiness (clean base fails 7; with this change 5;
    strict subset — zero new failures).

Merge note (AD-04)

Base branch macos-anr-mitigation is cut from v0.9.0-hvn.2 (792b8cf4), which is now ==
origin/master, so this is a clean single-commit diff into master. No auto-merge.

vietairs and others added 30 commits July 22, 2026 23:15
Add a one-shot "Balance splits" action and a persistent "Auto-resize
splits" toggle to the pane right-click menu.

Balance equalizes pane areas using leaf-count weighting rather than
equal split ratios, so panes get the same area regardless of tree
depth. Ratios stay within the existing 0.1..0.9 clamp; splits beyond
a 9:1 leaf ratio balance best-effort.

The toggle is an opt-in [ui] config key, off by default. It rebalances
only the ancestor chain of the pane that was added or closed, so
manual ratios elsewhere in the tab survive. Balancing is a no-op while
a pane is zoomed, since the zoomed view resizes only the focused pane
and would otherwise corrupt ratios that reappear after un-zoom.

Adds the layout.balance JSON-RPC method, mirroring the existing split
ratio handler. Ratio-only changes do not trigger federation resync, so
mounted remote workspaces may show stale ratios until a structural
event forces one.
…ncestor

Closing a direct child of the root produced a length-1 path, which
saturating_sub(2) clamped to an empty path. An empty path is not a no-op:
balancing walks the node it stands on before consuming the path, and after
the collapse that node is the promoted sibling, so its manual ratio was
silently reset. When the removed pane's parent was the root, no ancestor
survives and nothing may be balanced.

Also flip the auto-resize toggle from the value its menu label was rendered
from rather than from live state, so a config reload while the menu is open
cannot make the click do the opposite of what the item says.

Correct the socket-api note on split ratios under federation: a resync
reconciles workspaces, tabs, and panes and never applies layout ratios, so
stale mirror ratios are not recovered by a structural event.
The translation from a pre-close pane path to the ancestors that survive
remove_pane's collapse lived at the call site and was wrong twice, each
time one level shallower. The underlying trap is that an empty path is not
"no ancestors" but "balance the root", so every case with no surviving
ancestor silently rebalanced the promoted sibling instead.

Move the rule into TileLayout::balance_areas_after_removal, where the
L < 2 case returns early rather than clamping into an empty path, and
cover it with a sweep over every tree shape with 2-5 leaves and every
removable pane. The sweep asserts the actual contract — a split that is
not an ancestor of the removed pane keeps its exact ratio — and also
asserts surviving ancestors are rebalanced, so a no-op cannot pass it.
…direct attach

Ownership was session wide, so any mount froze the size of every terminal on
the host — including ones the mounting client never opens, since it opens them
lazily. Track the terminals a mount actually drives instead, claimed on its
first federated resize or redraw and dropped wholesale when the lease goes.

The direct attach paths resized the PTY with no ownership check at all, so an
attach behind a mount left the mirror laid out for the wrong width with nothing
to restore the size on detach. A mounted controller holds the single-controller
lease, so it now outranks a direct attach the same way it outranks the host's
own render pass; attaching still works, at the mount's size.
…actor

The nudge slept 30ms between the jiggle and the restore, on the same thread
that serves the pane's PTY reads and user-input writes — so a drag-resize over
a mount stalled both in 30ms bursts for the length of the drag.

Schedule the restore on the run loop instead, bounding the poll timeout so it
fires on time, and flush it on actor exit so the PTY is never left jiggled. The
restore reads the current size when it fires, so a resize arriving inside the
window survives.
…rigin

feat: balance splits and auto-resize toggle in the pane context menu
… like a resize

Three fixes from the pre-merge review.

The claim matched terminal ids exactly while the resize beside it resolved
through resolve_terminal_target, so any target form the resolver accepts but an
exact match misses was resized without being claimed. Both go through the
resolver now.

A claim was only ever dropped wholesale on unmount, so a terminal the mount
stopped mirroring stayed frozen at the mount's geometry while nobody drove it.
Closing a mirrored terminal now hands its size back; reopening re-claims it.

The claim was also untested through the command path — removing both call sites
left the suite green, because the tests called the helper directly. A dispatch
level test now covers claim-on-resize and release-on-close.
…spin

Truncating the remaining nudge window to whole milliseconds yields a zero
timeout for the final sub-millisecond, so the run loop polled with no timeout
and spun — draining commands and taking the controls mutex each pass — until
the restore deadline actually passed.
…der-on-origin

fix(federation): repaint mounted panes after a geometry change
…on link

A clipboard image pasted into a mounted remote pane had nowhere to go: the
capture path produces bytes, not a path, and the remote host cannot read the
local filesystem. Stage the bytes on the serving host instead and paste the
path the remote shell can actually open.

Adds a FileStaging channel (24 MiB cap) gated by a two-sided FILE_STAGING
capability, a detached bounded staging worker on the host with an RAII
admission permit, and the client-side pending-stage map with a
payload-proportional timeout, an in-flight cap, and epoch-fenced purges so a
stale or foreign response cannot evict a live entry.

Writes are guarded by an ordered sequence of checks that runs before the file
is created, and every failure path removes the partial artifact. Paths are
rejected rather than sanitised in place. Local panes and non-terminal modes
keep byte-identical ctrl+v behaviour.
…irectory

The staging-prefix check reads only the final component, so a returned path
containing .. passed every other guard and was pasted verbatim into the pane —
a hostile serving host could answer a clipboard stage with a path pointing at
any file the remote shell could read.

Reject any path with a . or .. component rather than normalising it: resolving
the traversal here would produce a path the host never wrote and hide the fact
that it answered a question it was not asked.
replaces contributions from othavi0
replaces contributions from wayneleelwc
replaces contributions from wbxl2000
replaces contributions from udirom
replaces contributions from soar
replaces contributions from sf-jin-ku
replaces contributions from DeevsDeevs
replaces contributions from Pitchfork-and-Torch
…o origin/master

Merges local master (v0.7.5 base, remote-workspace-paste-image-files
federation work) into origin/master (PRs #1-4: federation v2, TUI remote
workspace dialog, pane auto-resize, resize-rerender fix), two independently
evolved forks of the same project.

354 original conflicts resolved: vendor/libghostty-vt and website/docs taken
wholesale from origin (different vendored library snapshot; mixing would be
unsafe); everything else resolved per-file by comparing both implementations'
actual capability, not a blanket "one side wins" rule (confirmed both sides
led in different subsystems: origin ahead on federation protocol/vendor lib,
local ahead on the herdr agent CLI redesign).

Post-merge verification on two independent Linux machines (this repo's Mac
checkout has a pre-existing broken Zig/macOS-SDK toolchain, unrelated) found
and fixed:
- src/pty/actor/unix.rs silently destroyed by an earlier automated conflict
  resolution pass (511 lines vs 1241-1399 in the originals); re-resolved from
  git history, kept origin's non-blocking redraw-nudge design (confirmed a
  strict superset of local's blocking version)
- tests/cli_wrapper.rs, an obsolete monolithic test file fully superseded by
  local's modular tests/cli/ split, caused a real test-run hang via duplicate
  test names sharing derived resource paths; removed (coverage confirmed
  intact under the new names)
- ghostty_unicode_codepoint_width/ghostty_unicode_grapheme_width declared in
  origin's vendored C header but not exported by the compiled library;
  reimplemented in pure Rust via the already-linked unicode-width crate
  instead of depending on the vendored library gap
- Terminal::take_pwd_changes adapted to origin's simpler PWD polling API
  (origin's vendored library lacks local's OSC-push-callback mechanism)
- scroll_viewport_row adapted to origin's DELTA/TOP-only viewport API (no
  absolute-row variant in this vendored library version)
- OSC/PTY reply logic: added OSC 1337 CurrentDir= parsing, multi-index OSC 4
  palette query support, and a live-color-override reply path the merge had
  dropped (8 tests, src/pane/osc.rs + src/pane/terminal.rs)
- plugin manifest whitespace-only argv rejection bug (src/app/api/plugins/manifest.rs)
- regenerated the stale docs/next/api/herdr-api.schema.json artifact
- 8 integration tests in tests/cli/{panes,workspace}.rs hardcoded the old
  bare-numeric pane ID format instead of the new workspace-qualified one
  (w1:p1) that workspace create now returns
- two leftover references to the removed top-level `wait` CLI command
  (src/main.rs command whitelist + --help text)

Final state: 3145 lib tests + 193 integration tests passing, 0 failures, on
two independent machines (appn-ltu-vm-100, appn-ltu-vm-105).
…ocket-api

The merge's deliberate CLI redesign (herdr agent start/prompt/wait/send-keys
replacing herdr agent send and the top-level herdr wait command) was not
reflected in docs/next/website/src/content/docs/{cli-reference,socket-api}.mdx
across all 3 locales (EN/JA/ZH). Fixed:

- herdr agent send <target> <text> -> herdr agent prompt / agent send-keys
- herdr agent wait <target> --status X -> --until X (repeatable)
- herdr wait output / herdr wait agent-status (removed top-level command,
  confirmed removed from src/main.rs's command whitelist and --help text
  in this same merge) -> herdr pane wait-output / herdr agent wait
- socket-api.mdx method table and inline examples: agent.send -> agent.prompt,
  agent.wait, agent.send_keys, agent.view.set, agent.view.clear

Found by an independent breaking-change review of PR #5.
…ster

merge: reconcile local master's paste-image-files/federation work into origin/master
refs herdrdev#1752

Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
vietairs and others added 26 commits September 10, 2026 14:41
The refusal is evaluated per clipboard event rather than captured at mount
time, specifically so that revoking acceptance takes effect on the next
remote write instead of the next restart. Two comments say so. The reload
handler never refreshed the field, so it was not true.

The failure is in the unsafe direction: setting accept_clipboard_writes
to false and reloading reported "applied" with no diagnostics, and the
very next remote clipboard write was still accepted using the old value.
Only a full server restart picked the change up.

Found by the two-host smoke, not by a test: every existing test set the
state field directly, so none of them exercised the config path that
populates it.

The omission predates this merge, but the comments asserting live reload
were added by this branch's re-integration work, so the branch was
shipping a claim it did not keep.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Upstream v0.9.0 split remote arg parsing into its own module, and the
merge kept both that module's parser and the fork's own. Only the fork's
runs: every call site reaches extract_federated_remote_args, which parses
a target list rather than a single target and carries the federation
flag. Upstream's RemoteLaunch and extract_remote_args had no callers left.

They were also the last two code warnings on the branch, which CI denies.

The rest of the module stays, because all of it is still live: the two
env var names, RemoteKeybindings, and validate_remote_target.

This does not finish the consolidation. The fork's parser still lives in
attach.rs rather than here, so the next upstream merge will still conflict
across two modules. Moving it is a larger job than it looks -- the launch
arg cluster is about ten interleaved items whose cfg(not(unix)) dead-code
allowances are justified by the tests sitting beside them -- so it belongs
in its own commit, not folded into a merge this size.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The fork's clipboard origin travels server to client on
ServerMessage::Clipboard, and the client uses it to say which host a copy
came from instead of an unattributed "copied to clipboard". The merge kept
the behaviour but lost the test that pinned it: the surviving clipboard
tests in this file destructure ServerMessage::Clipboard { data, .. }, so
the origin could be dropped on the way out and every one of them would
still pass.

The client-side test only proves the toast formats a host it is handed. It
cannot notice the server never handing one over.

Verified by mutation: replacing the forwarded origin with None fails this
test and nothing else.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
… merge

The merge pulled upstream's preview snapshot into docs/preview while
website/preview.json still pins the fork's own preview commit, so the
snapshot check compared two unrelated trees and failed. Restore the
snapshot that matches the pin, and keep the fork's versions readme,
which still documents the website tooling this fork owns.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The upstream translation parity gate compares heading outlines, and the
fork's federation docs plus upstream's startup hooks section existed only
in English.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
…eader

The merge rehomed the terminal-drop bridge into src/client/clipboard_images.rs
and pointed both platforms at the local metadata-then-open copy. On unix that
undoes the hardening image_path owns: metadata() and open() on the same path
string are two syscalls, and the file can be swapped between them. Route the
unix read back through image_path::read_local_image_file, which takes every
fact from one file descriptor, and keep the local copy for windows, where
image_path is not compiled.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Dropping the standalone federated session mode left dial_federation,
dial_and_mount and their outcome types reachable only from the cfg(unix)
server-daemon mount path, and the same is true of the mount-outcome variants
and the shared close-index helper. The windows binary builds with -D warnings,
so each needs the allow the rest of this module already uses.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
v0.9.0's ci.yml runs the full `just check` on Windows instead of the
curated windows_check.ps1 filter list, so these three tests ran there for
the first time and hung. They drive a real local pane spawn, and on
Windows the ConPTY child outlives the dropped App, so the pane's
child.wait() blocking task never finishes and the test runtime's drop
blocks forever. The serving side these commands arrive on,
server::federation_accept, is cfg(unix) already.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Same ConPTY hang as the real-pane split/create tests: a real tab create
spawns a real local pane, the Windows child outlives the dropped App, and
the test runtime's drop never finishes. This was the only test still
running when nextest was interrupted, so the module has no others.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
chore(merge): merge upstream v0.9.0 into the fork
TerminateProcess needs PROCESS_TERMINATE, but signal_processes opened each
process with PROCESS_QUERY_LIMITED_INFORMATION alone and discarded the
result, so every terminate failed with access denied and was never
reported. The pane shutdown ladder's TERM and KILL rungs both silently did
nothing, leaving the ConPTY child alive and the pane's child.wait() watcher
blocked forever.

Open with PROCESS_TERMINATE as well and log a failed terminate. Removes the
cfg(unix) gates from the four federation_actor tests that this hang forced
off Windows, so the suite itself now covers the behaviour.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The federation mounting side was gated off Windows even though every
primitive it uses is already portable: the dial is a plain tokio ssh
process, the mount runs over the interprocess named-pipe abstraction, and
the mount dialog already compiled. Only the gates stood in the way.

Un-gate the mount path end to end: the workspace.mount_remote handler and
its unsupported_platform stub, the FederationMount{Ready,Failed,Ended}
events and their dispatch and fallback arms, dial_federation,
prepare_and_mount_federation_target, and dial_and_mount.

Clipboard file staging stays Unix-only, so the mount-ended handler gates
its pending-stage purge rather than the whole handler. The advertised
FILE_STAGING capability is unchanged: it describes the mounting side,
which only sends bytes and reads back a path, and the Windows client
never sends a stage request.

Serving federation from a Windows host is unaffected and still
unsupported.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
The three workspace.mount_remote rejection tests reject their target
synchronously, before any dial is spawned, so they run anywhere and now
give Windows real coverage of a handler it could not reach before. The
accepting sibling really does spawn dial tasks against ssh, so it stays
Unix-only rather than making CI depend on a Windows runner's ssh.

Also record the client-side support change and the pane-terminate fix in
the unreleased changelog and docs.

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
Review of the un-gating found two gates it had missed and three stale
justifications it had falsified.

The headless notifier still gated its FederationMountFailed arm on unix,
so on Windows the event fell through to the catch-all and no toast was
sent. The remaining fallback only fires for the herdr toast delivery
mode, so with terminal or system delivery a failed Windows mount was
silent -- exactly what that arm's comment says it exists to prevent.

--remote-workspace and HERDR_REMOTE_FEDERATION were also still gated, so
on Windows they fell through to a classic full-screen remote attach with
no word that the requested mode had been dropped. Un-gate that branch and
auto_detect_launch_with_mount; every helper they reach is already
cross-platform.

Drop three allow(dead_code) attributes whose comments claimed the items
had no non-unix caller, which the un-gating made untrue, and a
let _ = connection_epoch that suppressed a warning that cannot fire.

Record the platform split on the federation docs section that actually
changed, and note in local_capabilities that the FILE_STAGING advert is
honest only because app::remote_clipboard_stage is cfg(unix).

Claude-Session: https://claude.ai/code/session_01AqkeZHMPBQvpmf5vh4Eydp
fix(windows): terminate pane children with the required access right
feat(windows): let windows clients mount federated workspaces
…chain

Names in the four scopes drifted apart because each resolved independently.
A workspace rename froze its automatic label permanently, tabs never derived
a name from their directory at all and fell back to a bare position, and no
scope propagated a name to any other.

Resolve all four through one ladder: an explicit override, then a mounted
scope's mirrored remote label, then the pane's agent identity, then the cwd
or git root, then the scope's stable public ordinal. Every resolved name now
carries the rung that produced it, so callers can tell a name somebody set
from one derived locally instead of guessing from a one-bit flag.

Renaming a tab reaches the agents inside it that carry no name of their own,
including over the JSON API and the agent sidebar, while leaving the
addressable agent handle alone so `herdr agent send <name>` keeps working.
Clearing a rename snaps the scope back to its live derived name.

A mounted scope's remote label lands in a mirror slot rather than the
override slot, so it never inherits onto the panes inside it and never
freezes a remote pane's live agent identity. Federation-materialized
workspaces no longer drive the periodic local git pass.

The wire protocol goes 23 to 24 for the new name-source field. The
deprecated one-bit override flag stays, populated with its original meaning,
so endpoint clients built before this change keep working without a
generation bump. The federation protocol stays at 7: a new negotiated
capability tells the two sides apart, because an older peer omits the
name-source field entirely and its absence must not be read as a value.

Claude-Session: https://claude.ai/code/session_016SWJ3DTdN2Ny4vweAttAa1
The new naming capability carried a comment saying federation only
negotiates capabilities on Unix, and an allow(dead_code) attribute matching
two sibling constants. Enabling federated mounts for Windows clients removed
that attribute from those same siblings, so the comment now pointed at a
pattern that no longer existed and described a constant that has a live
Windows reader.

What the capability asserts is a property of this build's PaneInfo schema,
not of its operating system, so it is never platform-gated.

Claude-Session: https://claude.ai/code/session_016SWJ3DTdN2Ny4vweAttAa1
fix: sync workspace, tab, and agent names with the directory and with each other
The mount dialog, "Close on host" and "Balance splits" were rebuilt onto the
client-shell command lane without their methods being added to the list that
lane accepts, so every request they sent was dropped before reaching the
server and the controls looked inert.

Advertise workspace.mount_remote, workspace.close_remote, tab.close_remote
and layout.balance, and add a maintenance test asserting every method the
client shell sends is advertised. An unsupported server now reports itself on
the mount dialog rather than behind it.

Claude-Session: https://claude.ai/code/session_01G82QeFnX3UxWYfJUZ8sNkh
Only a server rejection landed on the mount dialog's error line. The three
client-side refusals -- endpoint offline, method not advertised, no snapshot
yet -- reported themselves solely through an endpoint notice, which the modal
is drawn over, so the press produced feedback nobody could see. Mirror
whichever refusal happened onto the dialog itself.

Tighten the surrounding verification: the mount test fixture now seeds the
real advertised method list instead of leaving it unset (unset is permissive,
so the existing tests passed straight through the outage), the lane test
asserts all four methods positively, and the advertisement guard reuses the
shared production_code() helper with a bidirectional canary so an advertised
method nobody sends is caught too.

Narrow request_changes_ui to layout.balance; workspace.close_remote and
tab.close_remote complete asynchronously through AppEvent and already repaint.

Claude-Session: https://claude.ai/code/session_01G82QeFnX3UxWYfJUZ8sNkh
fix(client-shell): advertise the methods the shell actually sends
…nder loop

A stuck far timer deadline plus silent channels parked the client loop in
its tokio select forever, with should_quit polled only at the loop head —
so Ctrl-C could not exit it and the host terminal showed a macOS
"Not Responding" dialog. Close the park three ways:

- Add a quit-wake arm: the ctrlc termination handler (SIGINT/SIGTERM/
  SIGHUP) now signals an Arc<Notify> that is a first arm of the select on
  both OS paths. A Notify permit is retained across iterations, so a
  termination signal can never be lost even if it lands between loop
  passes; the Quit arm re-stores the flag and lets the existing
  shutdown path run.
- Clamp ClientLoopTimer deadlines to MAX_LOOP_TIMER_DELAY (250 ms)
  before the sticky-min, so any far/stale deadline re-wakes the loop
  (quit-flag check + health tick) at least every 250 ms. Normal delays
  are unaffected; an earlier sticky deadline still wins.
- Probe local shell connections in the existing heartbeat: a
  silent-but-alive local server now surfaces as a 10 s health timeout
  (clean ConnectionLost) instead of an undetectable wedge. Local
  terminal-attach connections (no shell surface) stay exempt, so they
  cannot false-expire; the server already echoes pongs for every
  probed connection.

Also add ui.render_interval_ms (default 16, clamped to >= 1) as the
single source of the render/presentation pacing interval previously
hard-coded as MIN_RENDER_INTERVAL, so the headless render loop's
cadence is tunable without a rebuild; reloadable live via [ui] config.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • ai-review

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e67d819a-c197-4702-a20c-525d17765047

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kangal-bot

Copy link
Copy Markdown
Collaborator

Hi @vietairs, thanks for your interest in contributing.

Herdr does not accept unsolicited implementation pull requests from contributors who are not listed in .github/APPROVED_CONTRIBUTORS.

The pull request author is not an approved contributor.

If you encountered a reproducible bug, report the observed behavior through the bug issue template. A report does not reserve the work or authorize a pull request; accepted fixes are normally implemented by Herdr’s maintainer-controlled agents.

Feature requests, behavior changes, and other proposals belong in GitHub Discussions. Do not open an issue merely to justify an implementation that was already written.

If a maintainer explicitly wants this implementation, they can reopen the pull request. Reopening by anyone else will be closed again automatically.

See https://github.com/herdrdev/herdr/blob/master/CONTRIBUTING.md for the contribution policy.

@vietairs

Copy link
Copy Markdown
Author

Accidental push: this branch belongs to the fork vietairs/herdr, not upstream. Closing; the change is being PR'd in the fork.

@kangal-bot kangal-bot closed this Sep 18, 2026
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.