agentmux_srv/backend/blockcontroller/shell/pty.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! PTY geometry resolution and platform-specific shell detection.
5
6use portable_pty::PtySize;
7
8use super::controller::ShellController;
9use crate::backend::obj::RuntimeOpts;
10
11/// PTY read buffer size (matches Go's 4096).
12pub(super) const PTY_READ_BUF_SIZE: usize = 4096;
13
14// There is deliberately no PTY_COALESCE_WINDOW here any more.
15//
16// It used to be 20ms: how long the output flusher waited, after the first
17// chunk of a batch, for more chunks to arrive before broadcasting — added to
18// cut the IPC/wakeup rate under a fast producer, per
19// `docs/reports/REPORT_RENDERER_CPU_UNBATCHED_PTY_OUTPUT_2026_09_11.md`. Its
20// own doc comment argued "20ms is well under human-perceptible latency, so a
21// keystroke echo still appears with no felt delay". That was wrong on the
22// path that matters: a keystroke into an idle pane echoes exactly one chunk,
23// so the window was never satisfied early and every echo paid the full 20ms
24// — more than a 60Hz frame — before it was even broadcast.
25//
26// The flusher now drains only what is already queued (see
27// `run_pty_output_flusher`), which keeps the batching the report asked for
28// under real bursts while adding nothing when idle. Reintroducing a timed
29// wait here would re-break typing; if one is ever genuinely needed for the
30// Windows-10 DWM flash that PR #208 originally chased, the bound set by
31// SPEC_TERM_DOUBLE_RAF_TEAROUT_2026_05_30 is ~2-4ms, not 20ms.
32
33/// Hard cap on how many bytes one coalesced batch accumulates before it is
34/// flushed regardless of the time window — bounds both broadcast latency and
35/// memory during a sustained, very fast burst (e.g. `yes` piped through a
36/// shell pane). 64x a single PTY_READ_BUF_SIZE read: generous headroom for
37/// real bursts (a build's stdout in a 20ms window) while still bounded.
38pub(super) const PTY_COALESCE_MAX_BYTES: usize = 64 * PTY_READ_BUF_SIZE;
39
40/// Capacity of the channel handing PTY-read chunks from the blocking read
41/// loop to the async coalescing flusher (reagentx P1 on PR #3206).
42/// `PTY_COALESCE_MAX_BYTES` only bounds the batch currently being
43/// assembled — it does nothing about chunks still sitting in the channel
44/// if the flusher falls behind the read rate (slow disk, lock contention,
45/// several busy panes at once). An unbounded channel there would let a
46/// sustained fast producer (`yes`, a large `cat`) grow queued memory
47/// without limit. 128 chunks x up to ~PTY_READ_BUF_SIZE each is ~512KiB of
48/// worst-case queued-but-not-yet-batched data per pane — real headroom
49/// above one coalesced batch, but strictly bounded. The read loop sends
50/// via `blocking_send`, which blocks the calling (already-blocking-pool)
51/// thread once the channel is full, propagating real backpressure to the
52/// PTY read rate — and from there, via the kernel's own PTY buffer, to the
53/// child process itself, the same way a slow terminal reader naturally
54/// backpressures a fast writer.
55pub(super) const PTY_CHANNEL_CAPACITY: usize = 128;
56
57/// Bound on how long the wait task waits for the coalescing flusher to
58/// drain trailing output after the direct child has already been reaped
59/// (codex P1 on PR #3206, third round). Normally resolves almost
60/// immediately: the read loop observes real PTY EOF once the direct child
61/// exits and drops `pty_tx`, closing the flusher's channel. But EOF depends
62/// on every process holding the PTY slave fd closing it, not just the
63/// direct child — a background descendant that inherited the fd and
64/// ignores SIGHUP can keep it open indefinitely, in which case the flusher
65/// would never see its channel close and its `JoinHandle` would never
66/// resolve on its own. An unbounded await here would then hang child
67/// reaping's own downstream cleanup (STATUS_DONE, run_lock release)
68/// forever, for a case that must never block them. The original 10s mirrored
69/// `persistent/process_waiter.rs`'s stdout-reader bound for the same
70/// descendant-held-descriptor scenario: generous enough that normal
71/// flushing (one already-queued drain plus a write, milliseconds) never trips
72/// it, but a hard ceiling so a genuinely stuck descendant can't hang pane
73/// teardown. Unlike `persistent/process_waiter.rs`'s bound, expiry does NOT abort the
74/// flusher (reagentx P1 on PR #3206, same round): there, one combined async
75/// reader task both reads and processes, so aborting it genuinely stops the
76/// read. Here reading (a separate `spawn_blocking` doing a raw, blocking,
77/// un-cancellable OS `read()`) and flushing are two different tasks —
78/// aborting only the flusher can't reclaim the read loop's thread either
79/// way, and would additionally make every later `blocking_send` find the
80/// receiver gone and silently drop it, permanently losing any further
81/// output from a still-live descendant. Letting the `JoinHandle` simply
82/// drop on timeout detaches the flusher (and transitively its read loop)
83/// to keep running independently in the background instead, so nothing
84/// produced after teardown gives up waiting is silently lost.
85///
86/// **1s, reduced from 10s (2026-09-15,
87/// `docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md` §12).** The
88/// reasoning above is unchanged and still correct — what changed is a
89/// measurement of how often the ceiling is actually hit. On Windows this
90/// bound is reached on *essentially every ordinary shell exit*, not as the
91/// rare stuck-descendant case it was sized for: ConPTY's own console-host
92/// process routinely outlives the direct child and holds the PTY open, so
93/// the read loop never sees EOF and the flusher never resolves on its own.
94/// Because `STATUS_DONE` is published only after this wait, every `exit`
95/// left the pane visibly inert for the full ceiling — the user-reported
96/// "terminal hangs when I type exit," reproduced in srv logs as a flat 10s
97/// gap between `process exited` and any further activity.
98///
99/// Waiting longer buys nothing in that case (the flusher cannot finish
100/// until an EOF that may never come — which is exactly why expiry detaches
101/// rather than aborts), while costing the full ceiling of dead UI time on
102/// every single exit. 1s keeps ~50x headroom over the legitimate case this
103/// bound exists for (a final flush is bounded by one drain of what is
104/// already queued, plus one FileStore write and broadcast) and cuts the
105/// pathological
106/// case's user-visible cost by 10x. Nothing else about the behavior
107/// changes: expiry still detaches the flusher and its read loop to keep
108/// running in the background, so late output is still never lost.
109pub(super) const FLUSHER_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
110
111/// How long to wait for the STRONG completion signal — the flusher task
112/// ending, which means the read loop saw true EOF and every byte the child
113/// produced is committed — before falling back to the bounded flush barrier.
114///
115/// Short on purpose. Where EOF works (Linux, macOS) it arrives within
116/// milliseconds of the child being reaped, so this grace is ample and the
117/// pre-barrier guarantee is kept intact. Where it never arrives at all
118/// (Windows/ConPTY — see `FLUSHER_DRAIN_TIMEOUT`) no amount of waiting helps,
119/// so anything longer is pure dead UI time.
120pub(super) const FLUSHER_EOF_GRACE: std::time::Duration = std::time::Duration::from_millis(100);
121
122/// Budget for the fallback flush barrier. Deliberately the REMAINDER of
123/// `FLUSHER_DRAIN_TIMEOUT` after `FLUSHER_EOF_GRACE`, so the two waits split
124/// one budget instead of each claiming a full one — the worst-case exit delay
125/// is unchanged from before the barrier existed, rather than doubled (muxreview
126/// P2 on PR #3261).
127pub(super) const FLUSHER_BARRIER_TIMEOUT: std::time::Duration =
128 FLUSHER_DRAIN_TIMEOUT.saturating_sub(FLUSHER_EOF_GRACE);
129
130/// Resolve the initial PTY geometry from the resync `rt_opts` payload.
131///
132/// The agent pane is a custom UI (not xterm.js), so the PTY never receives
133/// a `fitAddon` resize and must be born at the right width — otherwise the
134/// first batch of agent/tool output wraps at the fallback width until a
135/// post-spawn resize RPC lands, and that RPC races controller startup (it
136/// can fail outright). The frontend computes cols from the pane and passes
137/// them as `rtopts.termsize` on the `controllerresync` command (see
138/// `usePtyWidth.ts` / `launch-flow.ts`).
139///
140/// Falls back to the historical 25x200 default when `rt_opts` is absent,
141/// unparseable, or carries the serde-default termsize (`rows==0 && cols==0`,
142/// per `obj::is_default_term_size`). Per-field guards let a cols-only
143/// payload keep the default row count. Each axis is clamped to `[1, 1000]`
144/// so the `i64 → u16` cast is lossless and a bogus value cannot open a
145/// zero-size or wrapped-size PTY.
146/// See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
147pub(super) fn pty_size_from_rt_opts(rt_opts: &Option<serde_json::Value>) -> PtySize {
148 // Historical fallback geometry. Cols 200 keeps the agent-pane live-log
149 // from hard-wrapping at ~80 before the dynamic resize lands.
150 const DEFAULT_PTY_ROWS: u16 = 25;
151 const DEFAULT_PTY_COLS: u16 = 200;
152 let (mut rows, mut cols) = (DEFAULT_PTY_ROWS, DEFAULT_PTY_COLS);
153 if let Some(v) = rt_opts {
154 if let Ok(rt) = serde_json::from_value::<RuntimeOpts>(v.clone()) {
155 let ts = &rt.termsize;
156 // rows==0 && cols==0 is the serde default → treat as absent.
157 if !(ts.rows == 0 && ts.cols == 0) {
158 if ts.cols > 0 {
159 cols = ts.cols.clamp(1, 1000) as u16;
160 }
161 if ts.rows > 0 {
162 rows = ts.rows.clamp(1, 1000) as u16;
163 }
164 }
165 }
166 }
167 PtySize {
168 rows,
169 cols,
170 pixel_width: 0,
171 pixel_height: 0,
172 }
173}
174
175impl ShellController {
176 /// Inherent-method wrapper over [`pty_size_from_rt_opts`], preserving the
177 /// pre-split `ShellController::pty_size_from_rt_opts(...)` call site the
178 /// unit tests use.
179 #[cfg(test)]
180 pub(super) fn pty_size_from_rt_opts(rt_opts: &Option<serde_json::Value>) -> PtySize {
181 pty_size_from_rt_opts(rt_opts)
182 }
183
184 /// The size `start()` opens the PTY at, recorded for a paired device that
185 /// watches the terminal (`blockcontroller::term_size`); resizes are
186 /// recorded as they are sent (`blockcontroller::send_input`).
187 pub(super) fn spawn_pty_size(&self, rt_opts: &Option<serde_json::Value>) -> PtySize {
188 let size = pty_size_from_rt_opts(rt_opts);
189 crate::backend::blockcontroller::term_size::record(&self.block_id, size.rows, size.cols);
190 size
191 }
192}