[v0.1/P0] Add aggregate daemon admission and handshake limits #14
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#14
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). Supersedes the aggregate-budget part of #10.
Why
Per-resource ceilings do not compose into a process budget. Silent same-UID peers can also retain a task and file descriptor indefinitely before completing the handshake.
Scope
accept.Hello, and first operation.Acceptance criteria
at_capacity/refusedoutcomes rather than allocation failures.daemon status, spec, security model, and architecture docs describe the same accounting model.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-daemon/src/serve.rs:246-266: the accept loop spawnshandle_connectionfor every accepted stream with no count, permit, or gauge.DaemonCaps(serve.rs:141-165) carries onlyidle,max_sessions,agent,endpoint.exchange_bootstrap(serve.rs:1718-1753) awaitsread_client_bootstrapwith no timeout;handshake(serve.rs:1913-1935) awaitsreader.next_frame()forHellowith no timeout;wait_for_attach(serve.rs:424-427) awaits the first operation with no timeout.grep timeoutacrossfelis-daemon/srcandfelis-transport/srcfinds only test code (parse_sink.rs,lib.rs:428,framing.rs:355). A silent same-UID peer holds a tokio task, a fd, aFrameReader(READ_CHUNK8 KiB) and aBufWriter(WRITE_BUF_CAPACITY) forever.MAX_SESSIONS = 256(pool.rs:46) enforced viatry_reserve+ RAIISessionSlot(serve.rs:485-506,pool.rs:389-400). Per session: grid 512 MiB budget (REQ-605a,messages.rs:97), image store 256 MiB (pool.rs:38), decode reassembly 64 MiB. Per subscriber:SUBSCRIBER_BUFFER_CAP = 512 MiB(session_task.rs:46), eviction not backpressure. Subscriber count per session is unbounded; connections are unbounded. So the daemon-wide product is256 × ~832 MiB + connections × 512 MiB + pending PTY input (unbounded, #15).serve.rs:928-1005): sessions, image-store bytes, in-flight decodes, decode bytes, deepest subscriber queue.AttachFailure::SessionLimitReached→ CLIat_capacity(CHANGELOG Unreleased).RefusalReasonhas a single variantRole(felis-protocol/src/messages/conn.rs:84-88).One thing the issue understates: the recorded decision at
docs/reference/ipc.md:1802-1816anddocs/explanation/architecture/session-lifecycle.md:1049-1058is that subscriber outboxes are unbounded with a gauge and evicted on volume, never on time, because a bounded channel would apply one slow mirror's backpressure to the session task serving everyone. #14 must account for those bytes, not replace the eviction policy.Verdict
accept-with-changes. The scope list ("one admission object" accounting for connection phase, sessions, PTY threads, grid/scrollback, images, decode, subscribers, queued bytes, pending input) over-reaches for what is missing. Grid, image, and decode budgets are already per-session constants whose aggregate is
max_sessions × constant; re-accounting them through a second object adds a lock on the hot path for no new bound. What is actually unbounded is (a) connection count, (b) time spent pre-attach, and (c) pending PTY input (#15). Do these, and document the product of the fixed per-session constants as the daemon-wide budget rather than enforcing it twice.Principle check: principle 3 test ("changing a client config requires a daemon restart") is untouched;
control-surfaces.md:173-178records that limits are compiled in and the daemon reads no config; keep it that way (no--max-connectionsflag; aDaemonCapsfield for embedders/tests, asmax_sessionsalready is).Approach
felis-daemon
DaemonCapsgainsmax_connections: usize(compiled defaultMAX_CONNECTIONS = 1024inpool.rs, 4× sessions: every window plus a bridge, an observer, and afelis sessionscall per session) andhandshake_deadlines: HandshakeDeadlines { preface: 2 s, hello: 5 s, first_op: 30 s }.Arc<tokio::sync::Semaphore>sizedmax_connections, held in the serve loop;try_acquire_owned()atserve.rs:247beforetokio::spawn. TheOwnedSemaphorePermitmoves into the task and is dropped on every exit path (success, refusal, timeout, disconnect, panic, because tokio drops the future's captures on task failure). This is the "owned permits released on drop" the issue asks for;SessionSlotalready has that shape for sessions.Hellounder the deadline, then writeConnMsg::Refused { reason: RefusalReason::AtCapacity, detail: "the daemon is at N of M connections" }and close. AddRefusalReason::AtCapacity(proto enum tail value; a minor addition under #50's ledger, and swept into #30's 2.0 reset). ReadingHellofor an over-cap peer is bounded by the deadline and byMAX_READAHEAD(framing.rs:51), so it costs no allocation beyond the read buffer. Rationale to record: closing the socket without a frame would make "daemon busy" indistinguishable from "daemon crashed" tofelisand the bridge, which must surfaceat_capacity(retry) rather thandaemon(exit 2).read_client_bootstrap(preface + carrier block),handshake's firstnext_frame, andwait_for_attach's firstnext_frameintokio::time::timeout; a timeout is a newConnError::HandshakeTimeout { phase }logged atdebug(a peer that says nothing is not worth awarn). Only the first pre-attach frame is timed: a bridge (Opsmode) legitimately idles between verbs after its first operation, and aNotifyobserver idles for hours.ResourceKind::Connections(count, limitmax_connections, scopeDaemon) todaemon_status(serve.rs:965-1004);used=max_connections - semaphore.available_permits().connections × SUBSCRIBER_BUFFER_CAPas the queued-bytes ceiling instead of adding a second gauge.DaemonCapsshould reserve the field name (pty_input_budget) so #15 slots in without a second config surface.felis-client-core / felis-cli: map
ConnMsg::Refused { AtCapacity }during dial toConnectError::AtCapacity→ CLI/bridgeat_capacityerror kind (already exists for sessions,skills/felis/SKILL.md:234).Tests
serve/tests.rs:DaemonCaps { max_connections: 2, .. }viahandle_streamwith in-memory duplex streams: third dial getsRefused(AtCapacity); after one disconnect the next dial succeeds (permit released); a handler that returnsErrmid-handshake releases;tokio::time::pause+ advance to prove each of the three deadlines fires and releases the permit; a peer that sends the preface and stops is cut athello; a peer that completesHelloand stops is cut atfirst_op; a bridge idle after one op is not cut.max_connectionssmall enough to refuse; assertdaemon statusrowsconnections.used ≤ limitthroughout and equal to zero after teardown (proves every permit returned).Docs cascade
docs/reference/spec.md: new REQ-916 (connection cap + three deadlines + typed refusal); REQ-915 gains a sentence that the daemon-wide budget ismax_sessions × (grid + image + decode)+max_connections × SUBSCRIBER_BUFFER_CAP+ the #15 input budget; REQ-1102 row list gains connections.docs/reference/ipc.md: "Handshake" section gets a deadline table; "Backpressure" gets a paragraph that the pre-attach phase is time-bounded while steady state is volume-bounded (keeps the recorded "cut on volume, never on time" for subscribers accurate by scoping it).docs/explanation/security-model.md"Daemon IPC": bullets for the connection cap and silent-peer bound, with the rejected alternative (per-session subscriber cap) and "Revisit if a mirror count abovemax_connections / max_sessionsappears".docs/explanation/architecture/session-lifecycle.md(admission list at:56-64),docs/explanation/architecture/control-surfaces.md"Diagnostic verbs" (new row),docs/reference/cli.md"Daemon status".CHANGELOG.mdUnreleased: new limit (1024 connections, typedat_capacityon connect), handshake deadlines, new status row.skills/felis/SKILL.md§"Is the daemon healthy?": new row; note thatat_capacitycan now arrive at connect time, not only at spawn.Dependencies
Daemon-scoped, uncontroversial). TheRefusalReason::AtCapacityandResourceKind::Connectionsadditions are wire edits that must precede #30's 2.0 reset and appear in #50's send-authorization ledger. #12's order (#14 before #15/#16/#49) still holds.Risk/effort
M. Semaphore + three timeouts + one enum value is S; the tests and the six-document cascade are where the time goes. Main risk: a deadline too tight for the SSH relay path. The relay (
felis-daemon relay) dials the remote daemon locally after sshd has already authenticated, so the preface deadline starts only once the local connect lands; keepprefaceat 2 s regardless, but verify with the relay integration test before choosing numbers.Labels
Keep
priority/P0,release/v0.1.0. An unbounded, un-timed pre-attach phase reachable by any same-UID process is a daemon-availability hole the security model (security-model.md:269, "second-largest attack surface") already claims to close.Review amendments (round 1)
try_acquire_owned) in the accept loop beforetokio::spawn. When no permit is available, the refusal path is itself bounded by a second, small semaphore (MAX_REFUSALS_IN_FLIGHT, e.g. 16): with a refusal permit, spawn a task that runs only the fixed preface exchange and readsHellounder the handshake deadline, then writesRefused { AtCapacity }and closes; without one, drop the socket in the accept loop without writing anything. Silent peers can therefore hold at mostmax_connections + MAX_REFUSALS_IN_FLIGHTtasks and fds. Theconnectionsstatus row counts admitted permits only. Test: withmax_connections = 1andMAX_REFUSALS_IN_FLIGHT = 1, two silent extra dials leave exactly one refusal task alive and the third is closed immediately.