[v0.1/P0] Bound PTY and GUI outbound queues with real backpressure #15

Closed
opened 2026-09-03 16:18:00 +09:00 by natsukium · 1 comment
Owner

Parent: #12 (P0.1). Related to #10.

Why

The PTY writer and both GUI connection generations use unbounded channels. A blocked PTY reader or stalled socket can move arbitrary backlog into process memory, and PtyWriter::poll_flush currently means only “queued.”

Scope

  • Replace PTY and GUI outgoing channels with byte-bounded flow control.
  • Preserve key and paste ordering.
  • Coalesce replaceable state such as resize, focus, color scheme, and mouse motion.
  • Make PTY flush complete only after delivery to the OS writer or failure.
  • Integrate queue reservations with the daemon admission model where applicable.

Acceptance criteria

  • A blocked PTY reader cannot grow daemon memory without bound.
  • A stalled local or SSH writer cannot grow client memory without bound.
  • Ordered inputs are never reordered by coalescing.
  • Flush reports downstream failure and does not complete at enqueue time.
  • Stress tests cover full queues, disconnects, and shutdown without deadlock.
Parent: #12 (P0.1). Related to #10. ## Why The PTY writer and both GUI connection generations use unbounded channels. A blocked PTY reader or stalled socket can move arbitrary backlog into process memory, and `PtyWriter::poll_flush` currently means only “queued.” ## Scope - Replace PTY and GUI outgoing channels with byte-bounded flow control. - Preserve key and paste ordering. - Coalesce replaceable state such as resize, focus, color scheme, and mouse motion. - Make PTY flush complete only after delivery to the OS writer or failure. - Integrate queue reservations with the daemon admission model where applicable. ## Acceptance criteria - [ ] A blocked PTY reader cannot grow daemon memory without bound. - [ ] A stalled local or SSH writer cannot grow client memory without bound. - [ ] Ordered inputs are never reordered by coalescing. - [ ] Flush reports downstream failure and does not complete at enqueue time. - [ ] Stress tests cover full queues, disconnects, and shutdown without deadlock.
Author
Owner

Triage plan (2026-09-03)

Source-grounded triage against main at 69076d42, reviewed through seven rounds of an independent reviewer (pi sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.

Claim check

Accurate against HEAD.

  • PTY writer is unbounded and flush is a lie. crates/felis-pty/src/lib.rs:327-328 creates mpsc::unbounded_channel::<Vec<u8>>(); PtyWriter::poll_write (:262-273) always returns Ready(Ok(len)) after tx.send(buf.to_vec()); poll_flush (:275-277) returns Ready(Ok(())) unconditionally. The writer thread (:359-375) blocks in write_all; when the child stops reading its stdin (cat under SIGSTOP, a TUI busy in a syscall) every subsequent write_pty (serve/streaming.rs:904-910) still "succeeds" and the queue grows.
  • Nothing upstream bounds it. The connection's inbound pump sends SessionCmd::Input on a bounded channel of CMD_CHANNEL_CAPACITY = 64 (session_task.rs:68, serve.rs:1310), but the session task drains it straight into write_pty (session_task.rs:1583-1595), which never blocks, so the 64-slot channel bounds nothing. Each InputMsg::KeyBytes/Paste (messages/input.rs:85-88, Vec<u8>) may be up to the 64 MiB frame cap (#16).
  • Client outgoing channels are unbounded on both generations. crates/felis-client/src/main.rs:868 (first connection) and app_methods.rs:1560 (retarget) create mpsc::unbounded_channel::<OutgoingFrame>(); the writer task (main.rs:1735-1752) does write + flush per frame, so a stalled carrier (CarrierWriter: local socket or SSH stdio) leaves every send_input (Resize, FocusChange, ColorScheme, Viewport, mouse motion, keys, pulls) in process memory. There is no coalescing anywhere on this path (grep -i coalesc finds only the daemon's diff drain and the client redraw scheduler).
  • Scope boundary the issue respects. The daemon→client subscriber outbox is deliberately unbounded-with-gauge and evicted on volume (docs/reference/ipc.md:1802-1816, session-lifecycle.md:1049-1058). #15 is about the other two queues; it must not reopen that decision.

Verdict

accept-with-changes. The goal (byte-bounded, order-preserving, flush-means-delivered) is right. Two changes to the approach:

  1. Do not make the session task block on the PTY. A naive bounded PtyWriter makes write_pty await inside the session task's select loop, which serves every subscriber and the parser drain; a stuck child would then freeze diffs for all mirrors of that session. The bound belongs at the connection inbound pump, with permits that travel with the bytes.
  2. Split the client work. The daemon-side bound is the P0 (a same-UID felis sessions send loop into a stopped child grows the daemon without limit). The client-side bound is real but slow to exploit (mouse motion only in reporting modes, resize at human rate); keep it in scope but allow it to be the smaller half.

Principle 3 holds (no policy moves to the daemon). Principle 1: coalescing is a typed rule on message kinds, not a heuristic on content.

Approach

felis-pty (src/lib.rs)

  • Shared WriterState { queued: usize, written: usize, err: Option<io::Error>, waker: Option<Waker> } under the existing Mutex pattern used by LifecycleState (:322-326). poll_write enqueues and returns Pending when queued - written >= PTY_WRITE_PENDING_CAP (1 MiB, mirroring READ_PENDING_CAP), registering the waker; the writer thread advances written after each write_all, records the error on failure, and wakes. poll_flush returns Pending until written == queued or err is set, then Ready(Err) once. poll_shutdown = flush then close the sender. This makes "flush complete only after delivery to the OS writer or failure" literal.
  • The channel can stay unbounded in count: bytes are bounded by the gauge, and a bounded channel would add a second wake path for nothing.

felis-daemon

  • Per-session Arc<Semaphore> with PTY_INPUT_BUDGET permits (bytes, e.g. 1 MiB; a human cannot type it while stalled; a paste larger than it is refused by #16's MAX_PASTE_BYTES or split into permit-sized writes). pump_inbound (serve.rs:1301-1312) acquires payload.len() permits (acquire_many_owned) before attached.cmd.send(SessionCmd::Input { .. }); the permit rides inside the InputMsg envelope (SessionCmd::Input { sub, msg, permit }) and into the Vec<u8> handed to the writer thread (PtyWriter::write_owned(buf, guard)), dropping after write_all. While the budget is exhausted the pump stops reading the socket, so backpressure propagates to the peer's socket buffer and then to the client's bounded queue; the session task never waits. Ordering is preserved because permits are acquired in arrival order on one pump and the writer thread is FIFO.
  • Session-task-originated writes (focus report, color-scheme report, DA replies from streaming.rs:76-80) are tiny and unpaced; they use write_pty directly, which now truly flushes; bound them by the gauge above so a stuck child cannot stall the task beyond 1 MiB of its own replies (in practice never reached; the test below pins it).
  • Disconnect: dropping the pump drops any permit it holds; session end drops the semaphore. Shutdown: PtyWriter drop closes the channel, writer thread exits after its current write_all (existing contract at :256-260).
  • Report the budget in daemon status as ResourceKind::PtyInputBytes (scope Session), alongside #14's rows.

felis-client-core (new outgoing.rs, used by felis-client)

  • OutgoingQueue with a byte cap (CLIENT_OUTGOING_CAP, 4 MiB) and two rules: ordered kinds (KeyBytes, Paste, Detach, search/region requests, NextGridFrame) append; replaceable kinds (Resize, FocusChange, ColorScheme, Viewport, Mouse with MouseAction::Motion and no button) replace the queued item of the same kind in place, keeping its slot, so a Resize queued before a key still goes out before that key (the session task's "presentation before size before bytes" order, session_task.rs:1571-1580, depends on this).
  • Full queue: ordered kinds over the cap declare the connection lost (AppEvent::DaemonClosed), reusing the reconnect path; this is "cut on volume" mirrored from the daemon's policy and avoids both blocking the winit loop and silently dropping keystrokes. Rationale to record inline in ipc.md: 4 MiB of unacknowledged input over a socket that has not drained is a dead transport, not a slow one.
  • Pumps::spawn (main.rs:1708) takes the queue's receiver (a bounded mpsc::channel(256) fed by the queue's drain) instead of UnboundedReceiver.

Tests

  • felis-pty: writer over a pipe(2) whose read end is never read: poll_write returns Pending after PTY_WRITE_PENDING_CAP; flush completes only after the reader drains; closing the read end surfaces BrokenPipe from flush, not from write. Existing spawn_io_threads lifecycle tests keep passing.
  • felis-daemon: with the #[cfg(all(test, unix))] pty_steps harness, a stalled fake writer: the inbound pump parks after PTY_INPUT_BUDGET bytes while the session task keeps emitting diffs to a second subscriber; disconnecting the parked connection releases the budget; Unsubscribe and session teardown complete without deadlock under tokio::time::timeout.
  • felis-client-core: OutgoingQueue unit tests — order of ordered kinds, in-place replacement, cap accounting, and a proptest that any interleaving of ordered and replaceable pushes drains ordered items in push order.

Docs cascade

  • docs/reference/ipc.md "Backpressure": add the client→daemon direction (per-session input budget, connection-level acquisition, ordered vs replaceable kinds, cut on volume) and keep the existing daemon→client paragraph intact.
  • docs/reference/spec.md: REQ-103 note; new REQ-1011a (input budget; flush semantics); REQ-1102 row.
  • docs/explanation/security-model.md "Daemon IPC": pending-input bound bullet, with the rejected alternative (block the session task) and why.
  • docs/explanation/architecture/session-lifecycle.md "Slow subscribers": add a sibling subsection "Slow children: backpressure, not eviction" (the asymmetry is the decision worth recording: one child per session, so blocking the typist's connection is correct where blocking on a mirror was not).
  • CHANGELOG.md: input budget, connection cut on a stalled carrier, daemon status row.
  • skills/felis/SKILL.md: sessions send can now block on a child that is not reading; note how to see it (daemon status pty_input_bytes).

Dependencies

  • #16 first (or same series): MAX_PASTE_BYTES must be ≤ PTY_INPUT_BUDGET or the permit acquisition for one frame can never succeed; define both in the shared limits module #16 creates.
  • #14: the budget's DaemonCaps field and status row ride #14's admission shape.
  • #22 (reconnect after transport loss) is what the client's "cut on volume" falls back on; without it the window simply closes, which is acceptable for v0.1 but should be noted in the CHANGELOG entry.
  • #12's order (#14, then #15 coordinated with #16) holds.

Risk/effort

L. Three crates, a new queue type, and the deadlock surface between pump, session task, semaphore, and writer thread. Main risk: a permit dropped late (held across an await in the session task) that turns backpressure into a stall for all subscribers; the test with a second subscriber is the guard. Second risk: PtyWriter::poll_flush becoming real changes the timing of every existing write_pty caller; run the full pty_steps snapshot suite.

Labels

Keep priority/P0, release/v0.1.0 for the daemon half. If the release needs trimming, the client-side coalescing could be split out as P1 (v0.1.0) without weakening the daemon bound; do not defer the PtyWriter flush semantics, since #16's and #21's error reporting depend on flush meaning delivery.

Review amendments (round 2)

  • Pin MAX_PASTE_BYTES <= PTY_INPUT_BUDGET. acquire_many_owned(n) with n above the semaphore's total permits never completes, so a single oversized paste would wedge the pump forever. Decision: no chunking; the caps are pinned by const _: () = assert!(MAX_PASTE_BYTES <= PTY_INPUT_BUDGET); in the shared limits module (#16). Concrete values: PTY_INPUT_BUDGET = 16 MiB, MAX_PASTE_BYTES = 16 MiB, MAX_KEY_BYTES far below. A paste above the cap is refused at the sender (#16 validate()) and, if it arrives anyway, closes the connection with OverLimit before any permit is requested. Test: a paste of exactly MAX_PASTE_BYTES acquires its permits and completes against a stalled child once the writer drains; MAX_PASTE_BYTES + 1 is refused without touching the semaphore.

Review amendments (round 4)

  • Reserve the transformed byte count. Bracketed paste wraps the payload (streaming.rs:921-926, 12 bytes of ESC[200~ / ESC[201~), so the pump reserves the post-transform length, and the pin becomes MAX_PASTE_BYTES + PASTE_BRACKET_OVERHEAD <= PTY_INPUT_BUDGET (keep PTY_INPUT_BUDGET = 16 MiB, set MAX_PASTE_BYTES = 16 MiB - 64). Test: an exact-limit bracketed paste against a stalled writer acquires and completes.
  • AsyncWrite semantics. poll_write must not consume the buffer when it returns Pending: it checks the gauge first, registers the waker and returns Pending without copying when over the cap, and enqueues only on the Ready(Ok(n)) path.

Review amendments (round 5)

  • Conservative reservation, mode-independent. Bracketed-paste mode is read in the session actor (session_task.rs:1590-1593), after admission in pump_inbound (serve.rs:1147-1151), and child output can flip it in between. The pump therefore reserves payload.len() + PASTE_BRACKET_OVERHEAD for every InputMsg::Paste regardless of mode (and at least PASTE_BRACKET_OVERHEAD for an empty paste), payload.len() for KeyBytes, and the reservation rides with the message through the writer. Tests: empty, unbracketed, bracketed, and a mode flip between admission and handling all stay within the reservation.
## Triage plan (2026-09-03) Source-grounded triage against `main` at `69076d42`, reviewed through seven rounds of an independent reviewer (`pi` sol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision. ## Claim check Accurate against HEAD. - **PTY writer is unbounded and flush is a lie.** `crates/felis-pty/src/lib.rs:327-328` creates `mpsc::unbounded_channel::<Vec<u8>>()`; `PtyWriter::poll_write` (`:262-273`) always returns `Ready(Ok(len))` after `tx.send(buf.to_vec())`; `poll_flush` (`:275-277`) returns `Ready(Ok(()))` unconditionally. The writer thread (`:359-375`) blocks in `write_all`; when the child stops reading its stdin (`cat` under `SIGSTOP`, a TUI busy in a syscall) every subsequent `write_pty` (`serve/streaming.rs:904-910`) still "succeeds" and the queue grows. - **Nothing upstream bounds it.** The connection's inbound pump sends `SessionCmd::Input` on a bounded channel of `CMD_CHANNEL_CAPACITY = 64` (`session_task.rs:68`, `serve.rs:1310`), but the session task drains it straight into `write_pty` (`session_task.rs:1583-1595`), which never blocks, so the 64-slot channel bounds nothing. Each `InputMsg::KeyBytes`/`Paste` (`messages/input.rs:85-88`, `Vec<u8>`) may be up to the 64 MiB frame cap (#16). - **Client outgoing channels are unbounded on both generations.** `crates/felis-client/src/main.rs:868` (first connection) and `app_methods.rs:1560` (retarget) create `mpsc::unbounded_channel::<OutgoingFrame>()`; the writer task (`main.rs:1735-1752`) does write + flush per frame, so a stalled carrier (`CarrierWriter`: local socket or SSH stdio) leaves every `send_input` (`Resize`, `FocusChange`, `ColorScheme`, `Viewport`, mouse motion, keys, pulls) in process memory. There is no coalescing anywhere on this path (`grep -i coalesc` finds only the daemon's diff drain and the client redraw scheduler). - **Scope boundary the issue respects.** The daemon→client subscriber outbox is deliberately unbounded-with-gauge and evicted on volume (`docs/reference/ipc.md:1802-1816`, `session-lifecycle.md:1049-1058`). #15 is about the *other* two queues; it must not reopen that decision. ## Verdict **accept-with-changes.** The goal (byte-bounded, order-preserving, flush-means-delivered) is right. Two changes to the approach: 1. **Do not make the session task block on the PTY.** A naive bounded `PtyWriter` makes `write_pty` await inside the session task's select loop, which serves every subscriber and the parser drain; a stuck child would then freeze diffs for all mirrors of that session. The bound belongs at the *connection* inbound pump, with permits that travel with the bytes. 2. **Split the client work.** The daemon-side bound is the P0 (a same-UID `felis sessions send` loop into a stopped child grows the daemon without limit). The client-side bound is real but slow to exploit (mouse motion only in reporting modes, resize at human rate); keep it in scope but allow it to be the smaller half. Principle 3 holds (no policy moves to the daemon). Principle 1: coalescing is a typed rule on message kinds, not a heuristic on content. ## Approach **felis-pty** (`src/lib.rs`) - Shared `WriterState { queued: usize, written: usize, err: Option<io::Error>, waker: Option<Waker> }` under the existing `Mutex` pattern used by `LifecycleState` (`:322-326`). `poll_write` enqueues and returns `Pending` when `queued - written >= PTY_WRITE_PENDING_CAP` (1 MiB, mirroring `READ_PENDING_CAP`), registering the waker; the writer thread advances `written` after each `write_all`, records the error on failure, and wakes. `poll_flush` returns `Pending` until `written == queued` or `err` is set, then `Ready(Err)` once. `poll_shutdown` = flush then close the sender. This makes "flush complete only after delivery to the OS writer or failure" literal. - The channel can stay unbounded in *count*: bytes are bounded by the gauge, and a bounded channel would add a second wake path for nothing. **felis-daemon** - Per-session `Arc<Semaphore>` with `PTY_INPUT_BUDGET` permits (bytes, e.g. 1 MiB; a human cannot type it while stalled; a paste larger than it is refused by #16's `MAX_PASTE_BYTES` or split into permit-sized writes). `pump_inbound` (`serve.rs:1301-1312`) acquires `payload.len()` permits (`acquire_many_owned`) **before** `attached.cmd.send(SessionCmd::Input { .. })`; the permit rides inside the `InputMsg` envelope (`SessionCmd::Input { sub, msg, permit }`) and into the `Vec<u8>` handed to the writer thread (`PtyWriter::write_owned(buf, guard)`), dropping after `write_all`. While the budget is exhausted the pump stops reading the socket, so backpressure propagates to the peer's socket buffer and then to the client's bounded queue; the session task never waits. Ordering is preserved because permits are acquired in arrival order on one pump and the writer thread is FIFO. - Session-task-originated writes (focus report, color-scheme report, `DA` replies from `streaming.rs:76-80`) are tiny and unpaced; they use `write_pty` directly, which now truly flushes; bound them by the gauge above so a stuck child cannot stall the task beyond 1 MiB of its own replies (in practice never reached; the test below pins it). - Disconnect: dropping the pump drops any permit it holds; session end drops the semaphore. Shutdown: `PtyWriter` drop closes the channel, writer thread exits after its current `write_all` (existing contract at `:256-260`). - Report the budget in `daemon status` as `ResourceKind::PtyInputBytes` (scope `Session`), alongside #14's rows. **felis-client-core** (new `outgoing.rs`, used by `felis-client`) - `OutgoingQueue` with a byte cap (`CLIENT_OUTGOING_CAP`, 4 MiB) and two rules: *ordered* kinds (`KeyBytes`, `Paste`, `Detach`, search/region requests, `NextGridFrame`) append; *replaceable* kinds (`Resize`, `FocusChange`, `ColorScheme`, `Viewport`, `Mouse` with `MouseAction::Motion` and no button) replace the queued item of the same kind **in place**, keeping its slot, so a `Resize` queued before a key still goes out before that key (the session task's "presentation before size before bytes" order, `session_task.rs:1571-1580`, depends on this). - Full queue: ordered kinds over the cap declare the connection lost (`AppEvent::DaemonClosed`), reusing the reconnect path; this is "cut on volume" mirrored from the daemon's policy and avoids both blocking the winit loop and silently dropping keystrokes. Rationale to record inline in `ipc.md`: 4 MiB of unacknowledged input over a socket that has not drained is a dead transport, not a slow one. - `Pumps::spawn` (`main.rs:1708`) takes the queue's receiver (a bounded `mpsc::channel(256)` fed by the queue's drain) instead of `UnboundedReceiver`. **Tests** - `felis-pty`: writer over a `pipe(2)` whose read end is never read: `poll_write` returns `Pending` after `PTY_WRITE_PENDING_CAP`; `flush` completes only after the reader drains; closing the read end surfaces `BrokenPipe` from `flush`, not from `write`. Existing `spawn_io_threads` lifecycle tests keep passing. - `felis-daemon`: with the `#[cfg(all(test, unix))]` `pty_steps` harness, a stalled fake writer: the inbound pump parks after `PTY_INPUT_BUDGET` bytes while the session task keeps emitting diffs to a second subscriber; disconnecting the parked connection releases the budget; `Unsubscribe` and session teardown complete without deadlock under `tokio::time::timeout`. - `felis-client-core`: `OutgoingQueue` unit tests — order of ordered kinds, in-place replacement, cap accounting, and a proptest that any interleaving of ordered and replaceable pushes drains ordered items in push order. **Docs cascade** - `docs/reference/ipc.md` "Backpressure": add the client→daemon direction (per-session input budget, connection-level acquisition, ordered vs replaceable kinds, cut on volume) and keep the existing daemon→client paragraph intact. - `docs/reference/spec.md`: REQ-103 note; new REQ-1011a (input budget; flush semantics); REQ-1102 row. - `docs/explanation/security-model.md` "Daemon IPC": pending-input bound bullet, with the rejected alternative (block the session task) and why. - `docs/explanation/architecture/session-lifecycle.md` "Slow subscribers": add a sibling subsection "Slow children: backpressure, not eviction" (the asymmetry is the decision worth recording: one child per session, so blocking the *typist's* connection is correct where blocking on a mirror was not). - `CHANGELOG.md`: input budget, connection cut on a stalled carrier, `daemon status` row. - `skills/felis/SKILL.md`: `sessions send` can now block on a child that is not reading; note how to see it (`daemon status` `pty_input_bytes`). ## Dependencies - **#16 first** (or same series): `MAX_PASTE_BYTES` must be ≤ `PTY_INPUT_BUDGET` or the permit acquisition for one frame can never succeed; define both in the shared limits module #16 creates. - **#14**: the budget's `DaemonCaps` field and status row ride #14's admission shape. - **#22** (reconnect after transport loss) is what the client's "cut on volume" falls back on; without it the window simply closes, which is acceptable for v0.1 but should be noted in the CHANGELOG entry. - #12's order (#14, then #15 coordinated with #16) holds. ## Risk/effort **L.** Three crates, a new queue type, and the deadlock surface between pump, session task, semaphore, and writer thread. Main risk: a permit dropped late (held across an await in the session task) that turns backpressure into a stall for all subscribers; the test with a second subscriber is the guard. Second risk: `PtyWriter::poll_flush` becoming real changes the timing of every existing `write_pty` caller; run the full `pty_steps` snapshot suite. ## Labels Keep `priority/P0`, `release/v0.1.0` for the daemon half. If the release needs trimming, the client-side coalescing could be split out as P1 (`v0.1.0`) without weakening the daemon bound; do not defer the `PtyWriter` flush semantics, since #16's and #21's error reporting depend on flush meaning delivery. ## Review amendments (round 2) - **Pin `MAX_PASTE_BYTES <= PTY_INPUT_BUDGET`.** `acquire_many_owned(n)` with `n` above the semaphore's total permits never completes, so a single oversized paste would wedge the pump forever. Decision: no chunking; the caps are pinned by `const _: () = assert!(MAX_PASTE_BYTES <= PTY_INPUT_BUDGET);` in the shared limits module (#16). Concrete values: `PTY_INPUT_BUDGET = 16 MiB`, `MAX_PASTE_BYTES = 16 MiB`, `MAX_KEY_BYTES` far below. A paste above the cap is refused at the sender (#16 `validate()`) and, if it arrives anyway, closes the connection with `OverLimit` before any permit is requested. Test: a paste of exactly `MAX_PASTE_BYTES` acquires its permits and completes against a stalled child once the writer drains; `MAX_PASTE_BYTES + 1` is refused without touching the semaphore. ## Review amendments (round 4) - **Reserve the transformed byte count.** Bracketed paste wraps the payload (`streaming.rs:921-926`, 12 bytes of `ESC[200~` / `ESC[201~`), so the pump reserves the post-transform length, and the pin becomes `MAX_PASTE_BYTES + PASTE_BRACKET_OVERHEAD <= PTY_INPUT_BUDGET` (keep `PTY_INPUT_BUDGET = 16 MiB`, set `MAX_PASTE_BYTES = 16 MiB - 64`). Test: an exact-limit bracketed paste against a stalled writer acquires and completes. - **`AsyncWrite` semantics.** `poll_write` must not consume the buffer when it returns `Pending`: it checks the gauge first, registers the waker and returns `Pending` without copying when over the cap, and enqueues only on the `Ready(Ok(n))` path. ## Review amendments (round 5) - **Conservative reservation, mode-independent.** Bracketed-paste mode is read in the session actor (`session_task.rs:1590-1593`), after admission in `pump_inbound` (`serve.rs:1147-1151`), and child output can flip it in between. The pump therefore reserves `payload.len() + PASTE_BRACKET_OVERHEAD` for every `InputMsg::Paste` regardless of mode (and at least `PASTE_BRACKET_OVERHEAD` for an empty paste), `payload.len()` for `KeyBytes`, and the reservation rides with the message through the writer. Tests: empty, unbracketed, bracketed, and a mode flip between admission and handling all stay within the reservation.
Sign in to join this conversation.
No description provided.