Skip to content

Rebase PR #730: opt-in backpressure/discard flow control - #797

Closed
xz-dev wants to merge 3 commits into
MisterTea:masterfrom
xz-dev:pr730-rebased
Closed

xz-dev wants to merge 3 commits into
MisterTea:masterfrom
xz-dev:pr730-rebased

Conversation

@xz-dev

@xz-dev xz-dev commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This follow-up carries Ben Maurer's three-commit PR #730 stack rebased onto current MisterTea/EternalTerminal@b74a12ef (master as of 2026-08-06). Original authorship and commit order are preserved.

Upstream PR: #730
Related issues: #631, #787, #782

Why this matters beyond interactive lag: we reproduced a recovery deadloop under fast terminal output. With the default none mode, stale output grew into a multi-megabyte queue; reconnect repeatedly hit the recovery timeout and restarted from the beginning. In our exact v7 baseline reproduction, recovery never finished. A long-running tmux/Pi session on a lossy, high-RTT path can therefore appear permanently frozen even though the remote program and etserver remain alive. This matches the failed-recovery class reported in #787; #782 explains why users only see a frozen terminal while reconnect/recovery progress exists only in logs.

With this patch and explicit --flow-control backpressure, matched-binary tests bounded the queue, stalled the producer while congested, resumed it after recovery, and returned display lag to about 1–2 seconds. discard also recovered while allowing producer progress, with the documented scrollback trade-off. Default behavior remains unchanged unless a client opts in.

Validation on the current rebased branch:

  • cmake -S . -B build -DDISABLE_VCPKG=ON -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo
  • cmake --build build -j1
  • ctest --test-dir build --output-on-failure -j1: 159/159 passed, 2 privilege-dependent skips
  • clang-format 18.1.8 dry-run: passed
  • git diff --check origin/master..HEAD: passed
  • repeated bounded E2E comparisons: none timed out; backpressure and discard recovered
  • production canary against an existing v7 server: backpressure recovered without restarting the server
  • real Tailscale-path canary: exact ~10 s outage, producer stall/resume, no Socket Timeout, display caught up below 2 s

The only rebase conflict was in TerminalClient::run; resolution preserves both PR #730 output-buffer draining and current master’s #788 non-TTY guard (console && consoleFd >= 0).

This is intended to help the original PR move forward, not replace its author. If Ben can update #730 directly, this branch can be used as a tested rebase reference or cherry-picked as the same three-commit stack.

waitOnSocketData treated EBADF/EINVAL from select() like EINTR and
returned false. Callers (SocketHandler::readAll,
RawSocketUtils::readAll) treat false as "retry", but a closed fd never
becomes readable again, so an fd closed by another thread spun at 100%
CPU. Throw instead, so retry loops treat it as socket death like any
other read failure.

The etterminal and jumphost init loops read packets outside any
try/catch; give them one so a router that dies during init produces a
logged fatal instead of an uncaught exception.
…isterTea#631)

When a process produces output faster than the network can deliver it,
the excess queues in kernel buffers - mostly the server's TCP send
buffer, which autotunes to many MB. On a saturated link the display
falls steadily behind real time and Ctrl-C appears dead: the interrupt
is delivered, but the prompt has to wait behind the entire queue. On a
100KB/s test link, Ctrl-C took 56-116s to take effect.

This adds a per-session flow control mode, chosen by the client:

- none (default): the existing behavior, unchanged. The mode field is
  not even serialized when unset, so new binaries interoperate with old
  ones exactly as before, and users who don't opt in see zero change.

- backpressure: terminal output is staged in a small (64KB) buffer that
  is drained only when select() reports the socket writable; when it
  fills, the server stops reading the PTY and the remote process pauses,
  like plain ssh. Lossless - safe for consumers that need every byte,
  such as tmux -CC.

- discard: same buffer, but when it fills the oldest chunk is dropped.
  The remote process never stalls - even while the client is
  disconnected - and the display stays near real time. Output that
  exceeded the link rate is missing from scrollback.

Application-level buffering only helps if the backlog actually waits in
the application buffer, so opted-in sessions also shrink the kernel
queues via SocketHandler::minimizeKernelBuffering (a no-op by default):

- TCP_NOTSENT_LOWAT=32KB on the client connection: select() reports
  writable only when <32KB is unsent, keeping the backlog in the
  WriteBuffer where it can be gated or dropped. In-flight
  (sent-but-unacked) data is not limited, so throughput on high-BDP
  links is unaffected. It is reapplied every loop iteration because the
  connection gets a new socket on reconnect and fd numbers are reused.

- SO_SNDBUF=64KB on the etterminal->etserver unix socket (pure queue,
  no in-flight component, so clamping costs nothing locally).
  etterminal learns the mode via a new TermInit field.

In the jumphost path, discard only drops TERMINAL_BUFFER packets;
control packets (port forwarding, responses, keepalives) are always
delivered, and reads pause when the queue is dominated by them so it
stays bounded. The client suppresses its missed-keepalive disconnect
while it is intentionally not reading (console buffer full), since the
keepalive echo may be sitting unread in the socket.

Measured on a 100KB/s throttled link with a 2.7MB/s producer:

                     display lag @30s   producer     Ctrl-C -> prompt
  none (unchanged)   ~31s, growing      throttled    56-116s
  backpressure       ~2.5s, bounded     throttled    2-3s
  discard            ~1.2s, bounded     full speed   2.1s

In discard mode the producer also runs at full speed through a 60s
network outage, and on reconnect the display recovers to ~1.2s of lag
instead of replaying the backlog.
A userspace throttle proxy (100KB/s, with a clamped receive buffer so
it models a real slow link instead of absorbing megabytes into its own
autotuned rcvbuf) sits between etserver and the et client, with a
timestamp-printing workload behind a PTY driven from tmux.

- do_scenario.sh: per-mode scenario (trunk/none/backpressure/discard)
  measuring display lag, producer lag (is the process stalled?),
  throughput, and Ctrl-C-to-prompt latency, with an optional 60s
  disconnect simulation. Emits JSONL metrics. Refuses to run on a
  dirty tree because its cleanup restores source dirs from HEAD.
- throughput_test.sh: bulk throughput on a fast link (no throttle).
- REPORT.md: methodology and measured results.

To run the components standalone, et gains --idpasskey to skip the SSH
handshake and connect directly to a manually started etterminal, and
etterminal's --idpasskey handling is fixed to actually use the passed
value (it was assigned to a shadowing local and dropped).
@xz-dev

xz-dev commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Validation context and linked production failure report are also posted on the original PR: #730 (comment). This follow-up should be treated as a tested rebase aid for #730.

@xz-dev
xz-dev marked this pull request as draft August 27, 2026 07:25
@xz-dev xz-dev closed this Aug 27, 2026
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.52189% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.13%. Comparing base (b74a12e) to head (396d07e).

Files with missing lines Patch % Lines
src/terminal/TerminalServer.cpp 59.49% 32 Missing ⚠️
src/terminal/UserJumphostHandler.cpp 20.00% 4 Missing ⚠️
src/terminal/UserTerminalHandler.cpp 42.85% 4 Missing ⚠️
src/base/PipeSocketHandler.cpp 75.00% 1 Missing ⚠️
src/base/SocketHandler.hpp 0.00% 1 Missing ⚠️
src/terminal/TerminalClient.cpp 96.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #797      +/-   ##
==========================================
- Coverage   87.14%   87.13%   -0.01%     
==========================================
  Files          75       77       +2     
  Lines        6462     6740     +278     
  Branches      610      650      +40     
==========================================
+ Hits         5631     5873     +242     
- Misses        831      867      +36     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@xz-dev

xz-dev commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the focused follow-up PR against Ben Maurer's branch: bmaurer#1

Closing this PR because the original contribution should remain primary. The focused follow-up preserves Ben's existing commits/authorship, merges current upstream, resolves the #788 conflict, and fixes the existing E2E cleanup bug without introducing another upstream-facing copy of PR #730.

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.

2 participants