[v0.1/P0] Bound PTY and GUI outbound queues with real backpressure #15
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#15
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_flushcurrently means only “queued.”Scope
Acceptance criteria
Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/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.
crates/felis-pty/src/lib.rs:327-328createsmpsc::unbounded_channel::<Vec<u8>>();PtyWriter::poll_write(:262-273) always returnsReady(Ok(len))aftertx.send(buf.to_vec());poll_flush(:275-277) returnsReady(Ok(()))unconditionally. The writer thread (:359-375) blocks inwrite_all; when the child stops reading its stdin (catunderSIGSTOP, a TUI busy in a syscall) every subsequentwrite_pty(serve/streaming.rs:904-910) still "succeeds" and the queue grows.SessionCmd::Inputon a bounded channel ofCMD_CHANNEL_CAPACITY = 64(session_task.rs:68,serve.rs:1310), but the session task drains it straight intowrite_pty(session_task.rs:1583-1595), which never blocks, so the 64-slot channel bounds nothing. EachInputMsg::KeyBytes/Paste(messages/input.rs:85-88,Vec<u8>) may be up to the 64 MiB frame cap (#16).crates/felis-client/src/main.rs:868(first connection) andapp_methods.rs:1560(retarget) creatempsc::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 everysend_input(Resize,FocusChange,ColorScheme,Viewport, mouse motion, keys, pulls) in process memory. There is no coalescing anywhere on this path (grep -i coalescfinds only the daemon's diff drain and the client redraw scheduler).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:
PtyWritermakeswrite_ptyawait 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.felis sessions sendloop 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)WriterState { queued: usize, written: usize, err: Option<io::Error>, waker: Option<Waker> }under the existingMutexpattern used byLifecycleState(:322-326).poll_writeenqueues and returnsPendingwhenqueued - written >= PTY_WRITE_PENDING_CAP(1 MiB, mirroringREAD_PENDING_CAP), registering the waker; the writer thread advanceswrittenafter eachwrite_all, records the error on failure, and wakes.poll_flushreturnsPendinguntilwritten == queuedorerris set, thenReady(Err)once.poll_shutdown= flush then close the sender. This makes "flush complete only after delivery to the OS writer or failure" literal.felis-daemon
Arc<Semaphore>withPTY_INPUT_BUDGETpermits (bytes, e.g. 1 MiB; a human cannot type it while stalled; a paste larger than it is refused by #16'sMAX_PASTE_BYTESor split into permit-sized writes).pump_inbound(serve.rs:1301-1312) acquirespayload.len()permits (acquire_many_owned) beforeattached.cmd.send(SessionCmd::Input { .. }); the permit rides inside theInputMsgenvelope (SessionCmd::Input { sub, msg, permit }) and into theVec<u8>handed to the writer thread (PtyWriter::write_owned(buf, guard)), dropping afterwrite_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.DAreplies fromstreaming.rs:76-80) are tiny and unpaced; they usewrite_ptydirectly, 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).PtyWriterdrop closes the channel, writer thread exits after its currentwrite_all(existing contract at:256-260).daemon statusasResourceKind::PtyInputBytes(scopeSession), alongside #14's rows.felis-client-core (new
outgoing.rs, used byfelis-client)OutgoingQueuewith 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,MousewithMouseAction::Motionand no button) replace the queued item of the same kind in place, keeping its slot, so aResizequeued 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).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 inipc.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 boundedmpsc::channel(256)fed by the queue's drain) instead ofUnboundedReceiver.Tests
felis-pty: writer over apipe(2)whose read end is never read:poll_writereturnsPendingafterPTY_WRITE_PENDING_CAP;flushcompletes only after the reader drains; closing the read end surfacesBrokenPipefromflush, not fromwrite. Existingspawn_io_threadslifecycle tests keep passing.felis-daemon: with the#[cfg(all(test, unix))]pty_stepsharness, a stalled fake writer: the inbound pump parks afterPTY_INPUT_BUDGETbytes while the session task keeps emitting diffs to a second subscriber; disconnecting the parked connection releases the budget;Unsubscribeand session teardown complete without deadlock undertokio::time::timeout.felis-client-core:OutgoingQueueunit 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 statusrow.skills/felis/SKILL.md:sessions sendcan now block on a child that is not reading; note how to see it (daemon statuspty_input_bytes).Dependencies
MAX_PASTE_BYTESmust be ≤PTY_INPUT_BUDGETor the permit acquisition for one frame can never succeed; define both in the shared limits module #16 creates.DaemonCapsfield and status row ride #14's admission shape.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_flushbecoming real changes the timing of every existingwrite_ptycaller; run the fullpty_stepssnapshot suite.Labels
Keep
priority/P0,release/v0.1.0for 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 thePtyWriterflush semantics, since #16's and #21's error reporting depend on flush meaning delivery.Review amendments (round 2)
MAX_PASTE_BYTES <= PTY_INPUT_BUDGET.acquire_many_owned(n)withnabove the semaphore's total permits never completes, so a single oversized paste would wedge the pump forever. Decision: no chunking; the caps are pinned byconst _: () = 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_BYTESfar below. A paste above the cap is refused at the sender (#16validate()) and, if it arrives anyway, closes the connection withOverLimitbefore any permit is requested. Test: a paste of exactlyMAX_PASTE_BYTESacquires its permits and completes against a stalled child once the writer drains;MAX_PASTE_BYTES + 1is refused without touching the semaphore.Review amendments (round 4)
streaming.rs:921-926, 12 bytes ofESC[200~/ESC[201~), so the pump reserves the post-transform length, and the pin becomesMAX_PASTE_BYTES + PASTE_BRACKET_OVERHEAD <= PTY_INPUT_BUDGET(keepPTY_INPUT_BUDGET = 16 MiB, setMAX_PASTE_BYTES = 16 MiB - 64). Test: an exact-limit bracketed paste against a stalled writer acquires and completes.AsyncWritesemantics.poll_writemust not consume the buffer when it returnsPending: it checks the gauge first, registers the waker and returnsPendingwithout copying when over the cap, and enqueues only on theReady(Ok(n))path.Review amendments (round 5)
session_task.rs:1590-1593), after admission inpump_inbound(serve.rs:1147-1151), and child output can flip it in between. The pump therefore reservespayload.len() + PASTE_BRACKET_OVERHEADfor everyInputMsg::Pasteregardless of mode (and at leastPASTE_BRACKET_OVERHEADfor an empty paste),payload.len()forKeyBytes, 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.