feat(daemon): cap connections and time-bound the pre-attach phases #68
No reviewers
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!68
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/daemon-admission-limits-14-v2"
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?
Fixes #14.
What changed
The daemon admitted every dial and put no clock on the phases before a
connection names its subject, so nothing bounded how many connections
existed and a silent same-UID peer could hold a task and a descriptor
forever. This branch closes both.
whenever the connection's task ends — return, refusal, timeout,
disconnect, or panic. 1024 in shipped builds, compiled in like the
session cap. A dial past it is answered
Refused { AtCapacity }naming the count and the limit, then closed.
Hello, 30 s for the first operation. The first-operation deadlinecovers a window and a notification observer until each names its
subject; an
Opsconnection is exempt pastWelcome, becausefelis bridgelegitimately idles on its anchor between verbs.full daemon as the
at_capacityerror kind at exit1— the daemonanswered, so freeing a window makes the same command work.
felis doctorandfelis --versionsay "at capacity" rather than "notrunning";
felis daemon statusgains aconnectionsresource row.RefusalReason::AT_CAPACITYandResourceKind::CONNECTIONS. Neither degrades to another value: apeer below minor 6 is closed after the preface instead of being
refused by name (an EOF it already classifies as transient), and its
status reply omits the row. Tests pin both downgrade paths.
Doc cascade
docs/reference/ipc.md(the minor-6 ledger row, the refusal table, thehandshake section),
docs/reference/cli.md(theconnectionsrow, theat_capacityexit, the doctor and status examples at the negotiatedminor),
docs/reference/spec.md(REQ-916),docs/explanation/security-model.md,docs/explanation/architecture/control-surfaces.md,docs/explanation/architecture/session-lifecycle.md, the shippedskills/felis/SKILL.md, and aCHANGELOG.mdentry for theuser-affecting CLI and wire changes.
Verified: just check green (fmt, clippy, nextest, deny), pi review PASS
(sol, round 2 — round 1's only blocker was against a pre-existing test
file this branch does not touch; both non-blocking findings were fixed).
Replaces #66, which was opened before
mainpicked up the per-operationIPC limits; that branch no longer merged cleanly against them. These are
the same ten commits cherry-picked onto current
main, with theCHANGELOG.mdanddocs/explanation/security-model.mdoverlaps resolvedso the connection cap sits beside the new per-operation limits rather
than replacing their frame-cap paragraph.
The per-resource ceilings never composed into a process budget: a same-UID peer could open sockets as fast as the kernel accepted them, and each accepted one bought a task, a file descriptor, a read buffer and a write buffer. Nothing capped how many there were, and nothing capped how long one could hold them without saying a word, so a silent peer retained all of it forever. The daemon now admits connections against an owned permit taken before the connection's task is spawned — the shape `SessionSlot` already has for sessions, so the ceiling holds under a burst instead of after it — and releases it whenever the task ends, including on panic. A dial past the cap is answered `Refused { AtCapacity }` rather than closed: a bare close is indistinguishable from a crashed daemon, and the caller must be able to retry rather than exit. Answering costs a task of its own, so the refusal path carries its own small ceiling, past which the socket is dropped unanswered. Each pre-attach phase gets its own deadline, and the first operation is timed only where silence means a stall. A window names its session in the frame after `Welcome` and an observer subscribes in it, so either one going quiet there is a peer that stopped. An `Ops` peer is the bridge shape instead: `felis bridge` dials its anchor at startup so that "the bridge is running" means "the daemon is reachable", then writes nothing until its editor asks for something, so timing that first frame would cut an idle bridge for working as documented. Past one operation nothing is timed at all. The deadline was never the bound against a hostile peer anyway — one cheap verb defeats any post-`Hello` deadline, and the count cap is what holds — so exempting a mode whose work is to wait costs nothing it was buying. Both numbers are compiled in, like the session cap — the daemon reads no config file (principle 3) — and `felis daemon status` reports the admitted count beside the limit. Re-accounting the already-fixed per-session grid, image and decode budgets through a second object was rejected: their aggregate is `max_sessions × constant` already, and a second gauge would add a lock to the hot path for no new bound. Refs #14 Assisted-by: Claude Code