Skip to content

Add opt-in flow control: --flow-control {backpressure,discard} (#631) - #730

Open
bmaurer wants to merge 3 commits into
MisterTea:masterfrom
bmaurer:backpressure-flow-control
Open

Add opt-in flow control: --flow-control {backpressure,discard} (#631)#730
bmaurer wants to merge 3 commits into
MisterTea:masterfrom
bmaurer:backpressure-flow-control

Conversation

@bmaurer

@bmaurer bmaurer commented Jan 11, 2026

Copy link
Copy Markdown

Problem (#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–116 seconds to take effect.

Design: strictly opt-in flow control

This revision makes flow control a per-session mode chosen by the client, with the default being no change at all:

  • --flow-control none (default) — the existing code path, unchanged. The proto field isn't even serialized when unset, so new/old clients and servers interoperate exactly as before. Users who don't opt in are unaffected.
  • --flow-control backpressure — terminal output is staged in a small (64KB) buffer 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, e.g. tmux -CC.
  • --flow-control discard — same buffer, but when full 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.

Kernel buffer tuning (opt-in sessions only)

Application-level buffering only helps if the backlog actually waits in the application buffer, so opted-in sessions also shrink the kernel queues (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. Reapplied each loop iteration because reconnects replace the socket (and fd numbers get reused).
  • SO_SNDBUF=64KB on the etterminal→etserver unix socket (a pure queue with 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.

Measured results (100KB/s throttled link, 2.7MB/s producer)

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

With discard, 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.

Commits

  1. Fix busy-loop when a socket closes during waitOnSocketData — standalone bugfix: EBADF/EINVAL from select() was treated like EINTR, so readAll retry loops spun at 100% CPU on a concurrently-closed fd.
  2. Add opt-in flow control — the feature itself, with unit tests (WriteBufferTest) and an integration test covering both opt-in modes end-to-end (ServerFlowControlDataTransferTest).
  3. Add E2E flow-control test harness — throttle-proxy harness measuring display lag, producer stalls, and Ctrl-C latency per mode, with disconnect simulation; methodology and full results in test/e2e/REPORT.md.

Fixes #631

@MisterTea

Copy link
Copy Markdown
Owner

Cool! I'm glad you had a chance to look into this!

Question: the way I typically use ET is I kick off a training job and then close my laptop. Will this stall the training process? Do we need logic that disables flow control when there's no client connected?

@codecov

codecov Bot commented Jan 12, 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 86.93%. Comparing base (3698116) to head (201fdb4).
⚠️ Report is 5 commits behind head on master.

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     #730      +/-   ##
==========================================
- Coverage   87.01%   86.93%   -0.09%     
==========================================
  Files          72       74       +2     
  Lines        5639     5917     +278     
  Branches      531      572      +41     
==========================================
+ Hits         4907     5144     +237     
- Misses        731      773      +42     
+ Partials        1        0       -1     

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

@bmaurer

bmaurer commented Jan 12, 2026

Copy link
Copy Markdown
Author

Looking at TerminalServer.cpp, I think the flow control logic works like this:

  1. When client is connected (serverClientFd > 0):
    - Read from terminal → buffer data → drain to client when socket writable
    - Backpressure: stop reading from terminal when buffer is full (256KB)
  2. When client is disconnected (serverClientFd == -1):
    - serverClientFd > 0 check fails, so we never drain the buffer
    - But we still apply backpressure: if (terminalOutputBuffer.canAcceptMore()) { FD_SET(terminalFd, &rfd); }
    - Once buffer fills to 256KB, we stop reading from the pty master fd

Does that sound right?

At the end of the day, the behavior here is that eventually a write() to the console will buffer. that may or may not stall the app depending on implementation. Logic dictates that either (1) we need to have an infinite buffer or (2) we need to backpressure.

The correct approach to this is to use tmux -CC, which i believe has it's own flow control https://github.com/tmux/tmux/wiki/Control-Mode. Any other approach will either buffer infinitely or stall.

Maybe we should do a smaller buffer when you are connected to the server but a larger buffer when not.

@bmaurer

bmaurer commented Jan 12, 2026

Copy link
Copy Markdown
Author

btw, is there a good way to do an end to end test of this (like testing out et against a program that does log spew, have the client disconnect then reconnect) I struggled to vibe code that part

@MisterTea

Copy link
Copy Markdown
Owner

btw, is there a good way to do an end to end test of this (like testing out et against a program that does log spew, have the client disconnect then reconnect) I struggled to vibe code that part

I've been slammed with interviews lately but once I have some time, my plan (feel free to do this yourself) is to start a pytorch training job inside tmux -CC inside et, close the connection, then come back an hour later and make sure the training kept running.

@MisterTea

Copy link
Copy Markdown
Owner

By "close the connection" I mean turn off wifi

@bmaurer
bmaurer force-pushed the backpressure-flow-control branch from 9f4ac9a to 5240cc2 Compare July 11, 2026 04:02
@bmaurer bmaurer changed the title Add flow control with backpressure for terminal data Add opt-in flow control: --flow-control {backpressure,discard} (#631) Jul 11, 2026
@bmaurer

bmaurer commented Jul 11, 2026

Copy link
Copy Markdown
Author

I've reworked this PR substantially to address the concern raised here and in #631 — that always-on backpressure would cause the "process blocks while disconnected" problem.

The default is now byte-for-byte the current behavior. Flow control only activates when a client explicitly passes --flow-control backpressure or --flow-control discard; the new proto field isn't serialized when unset, so old/new clients and servers interoperate unchanged. Nobody is affected unless they opt in.

The two opt-in modes map to the two camps in the issue thread:

  • backpressure keeps today's lossless contract (slow client ⇒ process pauses, like plain ssh — safe for tmux -CC), but bounds the queue so Ctrl-C takes ~3s instead of a minute.
  • discard is for the "never stall my job" use case: the process runs at full speed even through a 60s disconnect, and the display stays ~1s behind real time; the cost is that output exceeding the link rate is missing from scrollback.

The other significant finding while measuring this: application-level buffering alone barely helps, because the real queue lives in the kernel's autotuned TCP send buffer (up to 20MB on my test host) where nothing can be gated or dropped. That's why opted-in sessions set TCP_NOTSENT_LOWAT (32KB) — it only limits the unsent queue, not in-flight data, so fat-link throughput is preserved. Before/after on a 100KB/s link: Ctrl-C 56–116s → 2.1s, display lag unbounded → ~1.2s. Full methodology and numbers are in test/e2e/REPORT.md, and the harness (test/e2e/do_scenario.sh) reproduces every row of the table, including the disconnect scenarios.

The branch is now a 3-commit stack (standalone EBADF busy-loop fix → feature → E2E harness); each commit builds and passes the test suite independently.

@bmaurer
bmaurer force-pushed the backpressure-flow-control branch from 5240cc2 to d6ca0cb Compare July 11, 2026 04:28
@bmaurer

bmaurer commented Jul 11, 2026

Copy link
Copy Markdown
Author

Rebased onto current master (v7.0.0) and re-validated everything.

The rebase was interesting because v7.0.0 independently landed adjacent work: disconnect buffering (#731, #762 — the connection now buffers up to 64MB while disconnected so processes don't freeze immediately), the PTY-input deadlock fix (#765), console output coalescing (#742), and port-forward fds in select() (#736). This PR now composes with all of that: the none path is exactly the new v7 code (including the canBufferWrite gating), coalescing is preserved in the opt-in client path, and the opt-in drain loops reuse the new isSocketWritable() helper (extended to tolerate a concurrently-closed fd instead of fatal-failing).

Re-measured on the v7.0.0 base, same harness (100KB/s link, 2.7MB/s producer):

mode display lag @30s Ctrl-C → prompt
none (v7 baseline, unchanged) ~32s, growing 115.8s
backpressure (opt-in) ~2s, bounded 2.7s
discard (opt-in) ~1.1s, bounded 2.1s

The v7 disconnect work doesn't change the connected-saturated-link behavior (writes still land in the autotuned multi-MB kernel send buffer), so #631 still reproduces on the baseline — the kernel-queue tuning here is what bounds it. Full test suite passes (130 tests) and the disconnect scenarios were re-run: in discard mode the producer runs at full speed through a 60s outage and the display recovers to ~1.1s of lag within one sample of reconnecting.

bmaurer added 3 commits July 11, 2026 07:53
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).
@bmaurer
bmaurer force-pushed the backpressure-flow-control branch from d6ca0cb to 201fdb4 Compare July 11, 2026 14:56
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.

Flow Control

2 participants