feat(daemon): cap connections and time-bound the pre-attach phases #66

Closed
natsukium wants to merge 10 commits from feat/daemon-admission-limits-14 into main
Owner

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.

  • Connection cap. One owned permit per served connection, released
    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.
  • Handshake deadlines. 2 s for the version preface, 5 s for
    Hello, 30 s for the first operation. The first-operation deadline
    covers a window and a notification observer until each names its
    subject; an Ops connection is exempt past Welcome, because
    felis bridge legitimately idles on its anchor between verbs.
  • Typed outcome, not an allocation failure. Every verb reports a
    full daemon as the at_capacity error kind at exit 1 — the daemon
    answered, so freeing a window makes the same command work. felis doctor and felis --version say "at capacity" rather than "not
    running"; felis daemon status gains a connections resource row.
  • Wire: protocol minor 6. RefusalReason::AT_CAPACITY and
    ResourceKind::CONNECTIONS. Neither degrades to another value: a
    peer 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, the
handshake section), docs/reference/cli.md (the connections row, the
at_capacity exit, the doctor and status examples at the negotiated
minor), docs/reference/spec.md (REQ-916),
docs/explanation/security-model.md,
docs/explanation/architecture/control-surfaces.md,
docs/explanation/architecture/session-lifecycle.md, the shipped
skills/felis/SKILL.md, and a CHANGELOG.md entry for the
user-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).

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. - **Connection cap.** One owned permit per served connection, released 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. - **Handshake deadlines.** 2 s for the version preface, 5 s for `Hello`, 30 s for the first operation. The first-operation deadline covers a window and a notification observer until each names its subject; an `Ops` connection is exempt past `Welcome`, because `felis bridge` legitimately idles on its anchor between verbs. - **Typed outcome, not an allocation failure.** Every verb reports a full daemon as the `at_capacity` error kind at exit `1` — the daemon answered, so freeing a window makes the same command work. `felis doctor` and `felis --version` say "at capacity" rather than "not running"; `felis daemon status` gains a `connections` resource row. - **Wire: protocol minor 6.** `RefusalReason::AT_CAPACITY` and `ResourceKind::CONNECTIONS`. Neither degrades to another value: a peer 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, the handshake section), `docs/reference/cli.md` (the `connections` row, the `at_capacity` exit, the doctor and status examples at the negotiated minor), `docs/reference/spec.md` (REQ-916), `docs/explanation/security-model.md`, `docs/explanation/architecture/control-surfaces.md`, `docs/explanation/architecture/session-lifecycle.md`, the shipped `skills/felis/SKILL.md`, and a `CHANGELOG.md` entry for the user-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).
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
Every dial failure collapsed into `daemon_unreachable` and exit 2,
which tells a script that felis could not ask. A daemon at its
connection cap did answer, and the remedy is the one `at_capacity`
already names for a spawn past the session cap: free something and
retry. Collapsing the two would make a script give up on a condition
that clears by itself.

`Dial::open` therefore yields the exit code its reporter used rather
than a bare `None`, since the two failure classes no longer share one.
The refusal is lifted out of the error by walking the source chain,
because both dial helpers wrap the `ConnectError` and only the
innermost one knows the daemon's answer. `felis bridge` reports the
same kind at the same exit code for its own opening dial: it is a verb
like any other, and a supervisor that reads exit 2 relaunches a helper
whose daemon is merely busy.

Two classifications had to be split for that to hold. A full daemon is
*transient* — a peer leaves and the permit is free — while every other
refusal is a verdict about this build's frames; the exhaustive match
now reaches into `RefusalReason` so a new reason cannot inherit an
answer by accident. But transience is not the autospawn question:
anything that happened after the connect landed proves a daemon holds
the socket, and spawning a second one over it fails its bind and
buries the daemon's own answer under a boot timeout. Autospawn
therefore asks whether the socket may be cold, which only a failure
before any peer spoke can be.

Refs #14

Assisted-by: Claude Code
The spec had no requirement for connection admission at all, and the
security model's "second-largest attack surface" section claimed to
close a hole that was open: an unbounded, un-timed pre-attach phase any
same-UID process could reach. REQ-916 states the bound, REQ-915 gains
the product the two admission limits and the fixed per-resource
ceilings make, and REQ-1102's row list gains the connections row.

The reference twins carry the resulting facts — the deadline table and
which modes it binds, the new refusal reason, the status row, the exit
code — while the explanation pages carry the argument and the rejected
alternatives with their revisit triggers: a per-session subscriber cap,
which bounds nothing a connection cap does not, and one blanket
connection-lifetime deadline, which would cut the two shapes whose
whole job is to hold still without buying a bound a hostile peer
cannot sidestep with one cheap verb.

Refs #14

Assisted-by: Claude Code
Two gaps left the admission bound weaker than REQ-916 states.

The first-operation deadline retired on the first frame of any kind,
so a `Window` that asked one pre-attach `Ops::List` and then went
silent held a permit forever — and the shipped GUI sends exactly that
frame first, reading the roster in `dial_and_land` before it attaches,
so the deadline never covered a real window's attach at all. It now
runs until the attach decision instead, which is what the handshake
table always promised.

The cap also cannot bound descriptors: 1024 connections need more than
the default soft `RLIMIT_NOFILE` grants on some hosts (256 on macOS),
and the daemon cannot raise its own limit under the workspace's
`unsafe_code = "deny"`. A flood therefore exhausts the table while
permits remain, and `accept` fails on a connection still in the
backlog — retried immediately, that spins the accept loop at full CPU
for as long as the exhaustion lasts. The loop now backs off after any
accept error. Sizing the cap for the smallest floor a supported host
might impose was rejected: it would refuse ordinary desks on every
host without such a limit, to buy a typed refusal the caller cannot
act on differently from a retry.

Refs #14

Assisted-by: Claude Code
`RefusalReason::AT_CAPACITY` and `ResourceKind::CONNECTIONS` degrade to
nothing: an unknown enum value is a decode failure that costs a peer
the connection, so the daemon closes an over-cap peer below minor 6
instead of naming the reason, and omits the connections row from its
status reply. Both promises were one inline comparison each with no
test behind them — every other test handshakes at the current minor,
so deleting either gate left the suite green while a real minor-5 peer
lost its connection. Every earlier minor addition carries a downgrade
test in this file; these two now do too.

Refs #14

Assisted-by: Claude Code
REQ-916 and the security model both promised a fixed backoff after
*any* accept error, but only the I/O arm sleeps. The gap is not a
missing sleep: an accept refused for the peer's credentials has
already consumed its backlog entry, so the loop is past the offending
peer and makes progress on its own, while a delay there would let a
flood from a foreign UID meter how fast legitimate dials are admitted
— the opposite of what the backoff is for. The normative wording now
says which failure it covers, and the arm that deliberately does not
back off says why it does not.

Refs #14

Assisted-by: Claude Code
The refusal read `served()` a moment after `admit()` had already
failed, so a peer that left in between made the daemon refuse a dial
with "at 1023 of 1024 connections" — a count the reader has to
reconcile with the refusal it is attached to. The failed admission is
itself the observation: at its linearization point every permit was
held, so the ceiling is the count, and reading a second, later number
could only contradict it.

Refs #14

Assisted-by: Claude Code
The prose and the resource table gained the row, but the sample
output and the machine object beside them still showed a minor-5
daemon with no connections in it — the first thing a reader copies to
know what the verb prints, disagreeing with what it now prints.

Refs #14

Assisted-by: Claude Code
The connection-admission work moved the protocol minor to 6, but the
`felis doctor` sample output still printed "negotiated wire 1.5". A
reader comparing their own output against the page would read the
mismatch as a version skew between client and daemon, which is exactly
what that row exists to report.

Assisted-by: Claude Code
docs(manual): list the at-capacity exit for daemon status
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m59s
bench / Criterion regression gate (pull_request) Failing after 2m10s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m3s
pr / nix flake check (pull_request) Successful in 17s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m50s
pr / generated code is current (pull_request) Successful in 2s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m40s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo test (Windows) (pull_request) Successful in 6m52s
pr / cargo check (MSRV) (pull_request) Successful in 25s
windows / cargo clippy (Windows cross) (pull_request) Successful in 18s
windows / frontend smoke (Windows) (pull_request) Successful in 1m48s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
53ad22c99f
The verb's exit-code summary named only 0 and 2, but `daemon status`
needs a connection of its own, so the new cap can refuse it with
`at_capacity` at exit 1 — a retryable outcome the summary implied did
not exist. The same paragraph is where a script author decides whether
a nonzero exit is worth retrying.

The handshake-deadline rationale also claimed `Hello` follows the
preface without a round trip; the client writes it only after reading
the daemon's reply, so the five seconds must cover that trip.

Assisted-by: Claude Code
natsukium closed this pull request 2026-09-04 12:51:07 +09:00
Author
Owner

Superseded by #68: main picked up the per-operation IPC limits while this PR's CI ran, so this branch no longer merges cleanly against them. #68 is the same ten commits cherry-picked onto current main, with the CHANGELOG and security-model overlaps resolved. Closed rather than force-pushed over the reviewed branch.

Superseded by #68: main picked up the per-operation IPC limits while this PR's CI ran, so this branch no longer merges cleanly against them. #68 is the same ten commits cherry-picked onto current main, with the CHANGELOG and security-model overlaps resolved. Closed rather than force-pushed over the reviewed branch.
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m59s
Required
Details
bench / Criterion regression gate (pull_request) Failing after 2m10s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m3s
pr / nix flake check (pull_request) Successful in 17s
Required
Details
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m50s
Required
Details
pr / generated code is current (pull_request) Successful in 2s
Required
Details
pr / wire schema is compatible with the base (pull_request) Successful in 8s
Required
Details
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m40s
Required
Details
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
Required
Details
windows / cargo test (Windows) (pull_request) Successful in 6m52s
Required
Details
pr / cargo check (MSRV) (pull_request) Successful in 25s
Required
Details
windows / cargo clippy (Windows cross) (pull_request) Successful in 18s
Required
Details
windows / frontend smoke (Windows) (pull_request) Successful in 1m48s
Required
Details
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
Required
Details

Pull request closed

Sign in to join this conversation.
No description provided.